{
  "name": "Unpoll — WhatsApp Agent API with n8n Cloud",
  "active": false,
  "nodes": [
    {
      "id": "cac8bd85-9bc6-5ba3-8296-adf81d5a3c7f",
      "name": "Receive Unpoll webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        0
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "unpoll-whatsapp-agent",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "webhookId": "d0411c29-161d-4e18-b664-790ca3cf3b22"
    },
    {
      "id": "3e0f862f-af3b-59b5-af5e-01e45e6acb85",
      "name": "Recover one pending reply",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        480
      ],
      "parameters": {}
    },
    {
      "id": "972a0705-8e01-5137-8f37-af8ede126cc9",
      "name": "Agent settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "agent",
              "name": "agent_id",
              "value": "REPLACE_WITH_UNPOLL_AGENT_UUID",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {
          "stripBinary": false
        }
      }
    },
    {
      "id": "b05a1797-e178-5889-88bd-180c8f2dbd56",
      "name": "Webhook request?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        440,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "479a1df8-0d26-535d-83f6-4b6d4c102d10",
              "leftValue": "={{ typeof $json.headers === 'object' && $json.headers !== null }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "51cfd2eb-b4ad-5f4c-bb8e-860ee8c792f5",
      "name": "Prepare signed bytes",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        -120
      ],
      "parameters": {
        "jsCode": "// Use only raw bytes. The parsed Webhook body is deliberately ignored.\nconst reject = (status) => [{ json: { admitted: false, status } }];\nconst headers = $json.headers ?? {};\nconst timestamp = headers['x-unpoll-timestamp'];\nconst signature = headers['x-unpoll-signature'];\nconst agentId = $json.agent_id;\nconst uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\nif (typeof agentId !== 'string' || !uuid.test(agentId)) return reject(503);\n// Node joins duplicate custom headers with commas; these expressions reject them.\nif (typeof timestamp !== 'string' || !/^(0|[1-9][0-9]{0,15})$/.test(timestamp) ||\n    typeof signature !== 'string' || !/^v1=[0-9a-f]{64}$/.test(signature)) return reject(401);\nconst seconds = Number(timestamp);\nif (!Number.isSafeInteger(seconds) || Math.abs(Math.floor(Date.now() / 1000) - seconds) > 300) return reject(401);\nif (headers['content-encoding'] !== undefined && headers['content-encoding'] !== 'identity') return reject(400);\nlet raw;\ntry { raw = await this.helpers.getBinaryDataBuffer(0, 'data'); }\ncatch { return reject(503); }\nif (!raw.length || raw.length > 256 * 1024) return reject(413);\nconst signed = Buffer.concat([Buffer.from(timestamp + '.', 'utf8'), raw]);\nreturn [{\n  json: { admitted: true, agent_id: agentId, signature: signature.slice(3), raw: raw.toString('base64') },\n  binary: { signed: { data: signed.toString('base64'), mimeType: 'application/octet-stream' } },\n}];"
      }
    },
    {
      "id": "e497c1e1-9458-5a62-98f0-fac1bbbffafc",
      "name": "Headers valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        880,
        -120
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "7cf09e2e-fa9f-55d9-bb82-5727c4b2556f",
              "leftValue": "={{ $json.admitted === true }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "5db67ffd-ec84-57a4-8828-dc47d87a2592",
      "name": "Calculate expected HMAC",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 2,
      "position": [
        1100,
        -240
      ],
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "binaryData": true,
        "binaryPropertyName": "signed",
        "dataPropertyName": "expected_mac",
        "encoding": "hex"
      },
      "onError": "continueErrorOutput"
    },
    {
      "id": "f607e638-1d1d-5128-8d64-20c882a1b9ec",
      "name": "Verify and select event",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        -240
      ],
      "parameters": {
        "jsCode": "const { timingSafeEqual } = require('crypto');\nconst reject = (status) => [{ json: { verified: false, status } }];\nif (typeof $json.expected_mac !== 'string' || !/^[0-9a-f]{64}$/.test($json.expected_mac) ||\n    typeof $json.signature !== 'string' || !/^[0-9a-f]{64}$/.test($json.signature)) return reject(401);\nconst expected = Buffer.from($json.expected_mac, 'hex');\nconst supplied = Buffer.from($json.signature, 'hex');\nif (!timingSafeEqual(expected, supplied)) return reject(401);\n// Only authenticated JSON is used for routing, deduplication, or replies.\nlet event;\ntry { event = JSON.parse(Buffer.from($json.raw, 'base64').toString('utf8')); }\ncatch { return reject(400); }\nconst uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\nif (!event || typeof event !== 'object' || Array.isArray(event) ||\n    typeof event.id !== 'string' || !uuid.test(event.id) ||\n    event.agent_id !== $json.agent_id || event.version !== '2026-10-01' ||\n    !['message.received', 'message.status'].includes(event.type) ||\n    !event.data || typeof event.data !== 'object' || Array.isArray(event.data)) return reject(400);\nconst message = event.data;\nconst reply = event.type === 'message.received' && message.type === 'text' &&\n  typeof message.text?.body === 'string' && message.text.body.trim().length > 0;\nif (reply && (typeof message.from !== 'string' || message.from.length > 128 ||\n    !/^user:[\\x21-\\x7e]+$/.test(message.from))) return reject(400);\nreturn [{ json: {\n  verified: true, event_id: event.id, agent_id: event.agent_id,\n  recipient: reply ? message.from : null, state: reply ? 'pending' : 'ignored',\n} }];"
      }
    },
    {
      "id": "b3029ded-ad75-59f7-87a8-35c85cd14b72",
      "name": "Event verified?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1540,
        -240
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "95658752-69d8-5b9c-b1b0-e3256c2bd8ef",
              "leftValue": "={{ $json.verified === true }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "29f165f7-93dc-5d20-9d36-0c015ef0845a",
      "name": "Store durable inbox",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.8,
      "position": [
        1760,
        -360
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH inserted AS (\n    INSERT INTO public.unpoll_n8n_inbox (event_id, agent_id, recipient, state)\n    VALUES ($1::uuid, $2::uuid, $3, $4)\n    ON CONFLICT (event_id) DO NOTHING\n    RETURNING event_id\n)\nSELECT $2::uuid AS agent_id;",
        "options": {
          "queryReplacement": "={{ [$json.event_id, $json.agent_id, $json.recipient, $json.state] }}",
          "connectionTimeout": 3,
          "queryBatching": "single"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "id": "21a2b0e3-4d7f-5aee-968e-3fc80a0d4182",
      "name": "Acknowledge stored event",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.4,
      "position": [
        1980,
        -360
      ],
      "parameters": {
        "respondWith": "noData",
        "options": {
          "responseCode": 204
        }
      }
    },
    {
      "id": "e8e8c77e-4ebd-5a21-b0a3-2162073b5af2",
      "name": "Reject request",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.4,
      "position": [
        1760,
        0
      ],
      "parameters": {
        "respondWith": "noData",
        "options": {
          "responseCode": "={{ $json.status }}"
        }
      }
    },
    {
      "id": "922ce012-d1b2-5aec-aad2-e328e961dc7f",
      "name": "Storage or credential unavailable",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.4,
      "position": [
        1980,
        -100
      ],
      "parameters": {
        "respondWith": "noData",
        "options": {
          "responseCode": 503
        }
      }
    },
    {
      "id": "47998f0a-5468-5568-baa5-92886e82a07e",
      "name": "Claim one pending reply",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.8,
      "position": [
        2200,
        300
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH candidate AS (\n    SELECT i.event_id\n    FROM public.unpoll_n8n_inbox AS i\n    WHERE i.agent_id = $1::uuid AND i.state = 'pending'\n      AND NOT EXISTS (\n          SELECT 1 FROM public.unpoll_n8n_inbox AS busy\n          WHERE busy.agent_id = i.agent_id AND busy.state IN ('sending', 'unknown')\n      )\n    ORDER BY i.received_at, i.event_id\n    FOR UPDATE OF i SKIP LOCKED\n    LIMIT 1\n), claimed AS (\n    UPDATE public.unpoll_n8n_inbox AS i\n    SET state = 'sending', send_started_at = clock_timestamp()\n    FROM candidate AS c\n    WHERE i.event_id = c.event_id\n    RETURNING i.event_id, i.agent_id, i.recipient\n)\nSELECT * FROM claimed;",
        "options": {
          "queryReplacement": "={{ [$json.agent_id] }}",
          "connectionTimeout": 3,
          "queryBatching": "single"
        }
      }
    },
    {
      "id": "ffb4db83-fdb8-5535-9f93-4a36357ab7a4",
      "name": "Reply claimed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2420,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "e972dcd8-cc0e-50e7-ad29-82d64042eebe",
              "leftValue": "={{ typeof $json.event_id === 'string' && typeof $json.recipient === 'string' }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "036c4edc-67a9-5ec1-82dd-0f3fb487249c",
      "name": "Send text reply",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2640,
        180
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://api.unpoll.cc/v1/agents/' + $json.agent_id + '/messages' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { to: $json.recipient, text: \"Hello from n8n! Your message reached my WhatsApp agent.\" } }}",
        "options": {
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput"
    },
    {
      "id": "2bd522f0-9724-530a-849e-41dacbf626c6",
      "name": "Classify send outcome",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        180
      ],
      "parameters": {
        "jsCode": "// An HTTP 200 is acceptance only, not a delivery/read confirmation.\nconst response = $json;\nconst claim = $('Claim one pending reply').first().json;\nconst code = Number.isInteger(response.statusCode) ? response.statusCode : null;\nconst messageId = response.body?.message_id;\nconst accepted = code === 200 && typeof messageId === 'string' && messageId.length > 0;\n// Only documented, definitely-unsent Unpoll responses are safe to review/requeue.\nconst unsent = new Set([\n  '400:invalid_request', '401:invalid_api_key', '403:recipient_not_allowed',\n  '404:not_found', '405:method_not_allowed', '409:agent_disconnected',\n  '409:provider_credentials_rejected', '413:body_too_large', '422:provider_rejected',\n  '429:rate_limited', '429:send_in_progress', '503:provider_unavailable', '503:unavailable',\n]);\nconst definitelyUnsent = unsent.has(`${code}:${response.body?.error?.code}`);\nreturn [{ json: {\n  event_id: claim.event_id,\n  state: accepted ? 'accepted' : definitelyUnsent ? 'review' : 'unknown',\n  response_code: code, accepted_message_id: accepted ? messageId : null,\n} }];"
      }
    },
    {
      "id": "ed566767-b07d-52be-8987-2f801b855965",
      "name": "Record send outcome",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.8,
      "position": [
        3080,
        180
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE public.unpoll_n8n_inbox\nSET state = $2, response_code = $3, accepted_message_id = $4,\n    finished_at = clock_timestamp()\nWHERE event_id = $1::uuid AND state = 'sending';",
        "options": {
          "queryReplacement": "={{ [$json.event_id, $json.state, $json.response_code, $json.accepted_message_id] }}",
          "connectionTimeout": 3,
          "queryBatching": "single"
        }
      }
    }
  ],
  "connections": {
    "Receive Unpoll webhook": {
      "main": [
        [
          {
            "node": "Agent settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Recover one pending reply": {
      "main": [
        [
          {
            "node": "Agent settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Agent settings": {
      "main": [
        [
          {
            "node": "Webhook request?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook request?": {
      "main": [
        [
          {
            "node": "Prepare signed bytes",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Claim one pending reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare signed bytes": {
      "main": [
        [
          {
            "node": "Headers valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Headers valid?": {
      "main": [
        [
          {
            "node": "Calculate expected HMAC",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reject request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate expected HMAC": {
      "main": [
        [
          {
            "node": "Verify and select event",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Storage or credential unavailable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify and select event": {
      "main": [
        [
          {
            "node": "Event verified?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Event verified?": {
      "main": [
        [
          {
            "node": "Store durable inbox",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reject request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Store durable inbox": {
      "main": [
        [
          {
            "node": "Acknowledge stored event",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Storage or credential unavailable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Acknowledge stored event": {
      "main": [
        [
          {
            "node": "Claim one pending reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claim one pending reply": {
      "main": [
        [
          {
            "node": "Reply claimed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply claimed?": {
      "main": [
        [
          {
            "node": "Send text reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send text reply": {
      "main": [
        [
          {
            "node": "Classify send outcome",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify send outcome": {
      "main": [
        [
          {
            "node": "Record send outcome",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "executionTimeout": 60,
    "saveDataErrorExecution": "none",
    "saveDataSuccessExecution": "none",
    "saveManualExecutions": false,
    "saveExecutionProgress": false
  },
  "pinData": {},
  "tags": []
}
