Skip to content

Tickets service — n8n workflow guide

This document describes what an n8n workflow must do for the Dashboard Tickets service: which webhooks to listen on, what data the dashboard sends, what to POST back, and which tickets table columns are updated.

Overview

sequenceDiagram participant UI as Dashboard UI participant API as Tickets API participant DB as PostgreSQL tickets participant N8N as n8n workflow participant Plenty as Plenty Messenger UI->>API: POST /workflow/generate API->>DB: status=generating, placeholder content API->>N8N: Webhook (action=generate + context) N8N->>N8N: AI / rules / enrichment N8N->>API: POST /webhooks/n8n (generate_complete) API->>DB: Save AI draft on ticket row UI->>UI: Human reviews / edits draft UI->>API: POST /ai-response/send API->>Plenty: POST /rest/messages API->>DB: status=sent
Step Who Responsibility
Trigger generate Dashboard Sends ticket + message context to n8n
Draft AI reply n8n LLM, templates, recipient inference, etc.
Persist draft Dashboard callback Writes draft fields on tickets
Human review Dashboard UI Edit content, recipient, reference, whisper
Send reply Dashboard (not n8n) POST /rest/messages via Plenty credentials

n8n is only responsible for generating the draft. Sending to Plenty is done by the dashboard after a human clicks Send to Plenty.


Configuration

Configure in Tickets → Credentials → n8n Workflow (stored in n8n_workflow_settings), or via env fallback:

Setting DB column / env Purpose
Webhook URL webhook_url / N8N_TICKETS_WEBHOOK_URL n8n production webhook URL (dashboard POSTs here)
Webhook token webhook_token / N8N_TICKETS_WEBHOOK_TOKEN Shared secret (X-API-KEY header)
Callback URL callback_url / N8N_TICKETS_CALLBACK_URL Full public URL n8n POSTs back to

Default callback path (if callback URL not set in payload):

/api/v1/tickets/webhooks/n8n

Example full callback URL:

https://dashboard.example.com/api/v1/tickets/webhooks/n8n

The callback endpoint is public (no user JWT) but requires the same X-API-KEY token.


1. Inbound to n8n (dashboard → n8n)

Trigger

The UI calls:

POST /api/v1/tickets/tickets/{ticket_uuid}/workflow/generate
Content-Type: application/json

{
  "message_uuid": "<plenty-message-uuid-to-reply-to>"
}

The dashboard then POSTs the payload below to your configured n8n webhook URL.

Headers (dashboard → n8n)

Header Value
Content-Type application/json
X-API-KEY Webhook token from settings

Webhook body (JSON)

{
  "action": "generate",
  "ticket_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "message_uuid": "abc123-message-uuid",
  "callback_url": "https://dashboard.example.com/api/v1/tickets/webhooks/n8n",
  "ticket": {
    "title": "Question about order #12345",
    "text": "Guten Tag, full latest customer message text",
    "status": "IN_PROGRESS",
    "referrer_name": "Max Mustermann",
    "referrer_value": "customer@example.com",
    "from_name": "Max Mustermann",
    "from_value": "customer@example.com"
  },
  "conversation": [
    {
      "message": {
        "uuid": "abc123-message-uuid",
        "messageType": "newMessageFromContact",
        "createdAt": "2026-06-17T06:05:01.000Z",
        "title": "Question about order #12345",
        "text": "- Anhang: <invoice.pdf>\nCustomer question text",
        "attachments": [
          {
            "name": "invoice.pdf",
            "href": "https://dashboard.example.com/api/v1/tickets/webhooks/n8n/attachments/abc123-message-uuid/invoice.pdf",
            "contentType": "application/pdf"
          }
        ],
        "attachments_zip_href": "https://dashboard.example.com/api/v1/tickets/webhooks/n8n/attachments/abc123-message-uuid"
      },
      "is_target": true,
      "ai_response": null
    }
  ],
  "issue_categories": [
    { "key": "damaged_article", "label": "Damaged article" },
    { "key": "wrong_article", "label": "Wrong article" },
    { "key": "missing_item", "label": "Missing item" },
    { "key": "other", "label": "Other" }
  ]
}

The full issue_categories list contains 12 keys — see ticket_issue_categories.py in the backend.

Top-level fields

Field Type Description
action string Always "generate" for this flow
ticket_uuid string Plenty conversation UUID (primary key in tickets)
message_uuid string Plenty message UUID the reply should be linked to
callback_url string URL n8n must POST the result to
ticket object Ticket metadata (no duplicate uuid — use top-level ticket_uuid)
conversation array Inbound customer messages, oldest first, each with optional ai_response
issue_categories array Allowed ai_category keys for the LLM (key + label)

conversation[] entries

Field Description
message Slim inbound message (text is plain text — no raw HTML)
is_target true for the message being answered in this run
ai_response Prior reply to this message, or null

ai_response sources:

source Meaning
plenty Reply already sent in Plenty (linkedTo the inbound message)
dashboard Draft / generating / error state stored on the ticket row

Outbound Plenty replies are not duplicated as separate conversation entries.

ticket object fields

Field Description
title Conversation subject
text Full plain-text context for the target message (not the truncated preview)
status TODO, IN_PROGRESS, WAITING_FOR_RESPONSE, COMPLETED
referrer_name Customer / referrer display name
referrer_value Usually customer email
from_name Sender name
from_value Sender email / identifier

Slim message fields (inside conversation)

Field Description
uuid Message UUID
messageType e.g. newMessageFromContact
title Subject line
text Plain text for AI prompts (customer quote + optional - Anhang: <file> line; HTML stripped)
attachments Optional array of { name, href, contentType?, size? }href points at the dashboard proxy, not Plenty
attachments_zip_href Optional URL to download all message attachments as ZIP
createdAt / updatedAt Timestamps
categoryId Messenger category
whispered Internal note vs customer-visible

The target message attachments are also copied to ticket.attachments (same shape).

Downloading attachments from n8n

Plenty attachment URLs are not public. The webhook payload uses dashboard proxy URLs:

URL in payload Method Auth
attachments[].href GET Header X-API-KEY: <same token as callback>
attachments_zip_href GET Header X-API-KEY: <same token as callback>

Example n8n HTTP Request node:

GET {{ $json.ticket.attachments[0].href }}
Header: X-API-KEY = <your webhook token>
Response: binary file (image/jpeg, application/pdf, …)

Do not call https://p*.my.plentysystems.com/rest/messages/.../attachments/... directly from n8n — that requires Plenty Bearer auth and is not a browser-safe public URL.

Internally, single-file downloads are extracted from Plenty's /attachments/all ZIP (the per-file Plenty REST route often returns 404).

Use conversation[].message where is_target === true as the message being answered. Use the full conversation array for thread context.

Removed (no longer sent): target_message, raw messages with full HTML bodies, duplicate ticket.uuid.

What the dashboard sets before calling n8n

On POST /workflow/generate, the dashboard immediately updates the ticket row:

Column Value
ai_response_message_uuid message_uuid from request
ai_response_content "Generating response via n8n..."
ai_response_status "generating"
ai_response_generated false
ai_response_sent false
status IN_PROGRESS

If the n8n webhook call fails, the dashboard sets ai_response_status = "error" and stores the error in ai_response_content.


2. What n8n must do

Minimum workflow

  1. Webhook node — receive the dashboard payload (action = generate).
  2. Build prompt — use ticket, conversation (find is_target: true), and each entry's ai_response for prior replies.
  3. Generate reply — OpenAI, Anthropic, template engine, or rules.
  4. HTTP Request node — POST result to callback_url (see below).
  • Set recipient from ticket.referrer_value or ticket.from_value if the customer email is known.
  • Set reference to something like RE: {ticket.title}.
  • Set whisper to false for normal customer replies (true only for internal notes).
  • Classify the issue and set ai_category to one key from issue_categories in the webhook.
  • On failure, POST with action: "error" and an error message.

n8n does not need to

  • Call Plenty POST /rest/messages (dashboard handles send after human review).
  • Use action: send_complete / sent / send (legacy; send is no longer via n8n).
  • Write to PostgreSQL — no SQL, no direct DB access, no column names in n8n.
  • Call the Plenty API to “generate” a message — you only write reply text in content.

2b. TL;DR — what you actually do in n8n

Do you write to the database?

No. Never. The dashboard callback updates public.tickets for you.

Do you call Plenty?

No. Not for the standard flow. The dashboard already put Plenty message data in the webhook body (conversation).

What do you do?

One HTTP POST to callback_url when your AI (or template) is done.

Minimum callback JSON (only 4 fields)

{
  "action": "generate_complete",
  "ticket_uuid": "<copy from webhook>",
  "message_uuid": "<copy from webhook — same value you received>",
  "content": "<your generated reply text>"
}

That is enough for the draft to appear in the UI.

n8n Code node — callback body bauen (empfohlen)

Die meisten Antworten haben kein PDF. attachments nur mitschicken, wenn ein PDF wirklich erzeugt wurde.

Typische Agent-Ausgabe (z. B. Beschädigter-Artikel-Agent):

{
  "output": {
    "response": "Vielen Dank für Ihre Nachricht. …"
  }
}

Der Kundentext steht in output.response, nicht in pdfBase64.

Code node (vor HTTP Request → callback_url):

// Name des Webhook-Nodes anpassen, z. B. "Webhook" oder "Dashboard Webhook"
const webhook = $("Webhook").first().json;
const agent = $input.first().json;

const content =
  agent.output?.response || agent.output?.content || agent.content;

if (!content || !String(content).trim()) {
  throw new Error(
    "Agent-Antwort fehlt — erwartet output.response vom Agent-Node",
  );
}

const pdfBase64 = agent.output?.pdfBase64 || agent.pdfBase64;

const body = {
  action: "generate_complete",
  ticket_uuid: webhook.ticket_uuid,
  message_uuid: webhook.message_uuid,
  content: String(content).trim(),
};

// PDF nur wenn vorhanden — sonst weglassen (Normalfall)
if (pdfBase64 && String(pdfBase64).trim()) {
  body.attachments = [
    {
      name: agent.output?.pdfFileName || "dokument.pdf",
      contentType: "application/pdf",
      content_base64: String(pdfBase64).trim(),
    },
  ];
}

return [{ json: body }];

HTTP Request node:

  • Method: POST
  • URL: {{ $('Webhook').item.json.callback_url }}
  • Header: X-API-KEY = Webhook-Token
  • Body: JSON → Expression {{ $json }}

Häufige Fehler:

Problem Ursache
422 … attachments … valid list attachments als JSON-String statt Array — Code node + {{ $json }} nutzen
pdfBase64 is missing Code wirft Fehler obwohl kein PDF nötig — attachments nur bei vorhandenem PDF setzen
Leerer Draft content aus falschem Feld — bei eurem Agent: output.response

Optional callback fields (nice to have, not required)

JSON field in callback Required? If omitted
recipient No At send time dashboard uses ticket.referrer_value or ticket.from_value
reference No At send time dashboard uses RE: {ticket.title}
whisper No Defaults to false
status No Defaults to draft
ticket_status No Defaults to IN_PROGRESS
attachments No Nur bei PDF (z. B. eidesstattliche Erklärung) — siehe Code-Beispiel oben

Recommended: set recipient to {{ $json.ticket.referrer_value }} or from_value from the webhook if you have it — but humans can fix it in the UI before send.

message_uuid vs ai_response_message_uuid

Where Name
In the webhook n8n receives message_uuid
In the callback n8n sends message_uuid (echo it back)
In the database (dashboard writes) ai_response_message_uuid

You never send ai_response_message_uuid in the callback. You send message_uuid. The API maps it to the DB column.

You do not invent or fetch this UUID from Plenty — pass through the same message_uuid the dashboard sent you.

Mapping: callback JSON → database (for reference only)

The API applies this mapping when your callback succeeds. You do not set column names.

You send in callback JSON Dashboard writes to tickets column
message_uuid ai_response_message_uuid
content ai_response_content
recipient (optional) ai_response_recipient
reference (optional) ai_response_reference
whisper (optional) ai_response_whisper
status (optional) ai_response_status
ai_category (optional) ai_category
ticket_message_responses row upserted for the inbound message_uuid
ai_response_generated = true
ai_response_sent = false
ai_processed_at = now
ticket_status (optional) status

3. Outbound from n8n (n8n → dashboard callback)

Request

POST {callback_url}
Content-Type: application/json
X-API-KEY: <same webhook token as configured in dashboard>

Success — save draft (generate_complete)

Required fields:

Field Type Required Description
action string yes generate_complete, generate, or draft
ticket_uuid string yes Must match incoming webhook
message_uuid string yes Message being replied to
content string yes AI-generated reply text (plain text)

Optional fields:

Field Type Description
recipient string Customer email (TO). Fallback at send time: referrer_valuefrom_value
reference string Subject / reference line. Fallback: RE: {ticket.title}
whisper boolean false = visible to customer, true = internal whisper
status string Draft status; default draft
ticket_status string Ticket workflow status; default IN_PROGRESS
ai_category string Issue type key from issue_categories (e.g. damaged_article, wrong_article)

Example callback body

{
  "action": "generate_complete",
  "ticket_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "message_uuid": "abc123-message-uuid",
  "content": "Guten Tag,\n\nvielen Dank für Ihre Nachricht. Ihre Bestellung wurde versendet...\n\nMit freundlichen Grüßen\nIhr Team",
  "recipient": "customer@example.com",
  "reference": "RE: Question about order #12345",
  "whisper": false,
  "status": "draft",
  "ticket_status": "IN_PROGRESS",
  "ai_category": "damaged_article"
}

Example n8n HTTP Request node

  • Method: POST
  • URL: {{ $json.callback_url }} (from webhook body)
  • Headers: X-API-KEY = your token
  • Body: JSON as above

Error callback

{
  "action": "error",
  "ticket_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "error": "OpenAI rate limit exceeded",
  "content": "Optional longer error detail"
}

The API returns HTTP 400 but still persists error state on the ticket.


4. Database fields (public.tickets) — informational only

You do not touch these columns from n8n. This section explains what the callback API writes after your HTTP POST succeeds.

AI draft data lives on the ticket row (one draft per ticket). There is no separate ai_responses table in production.

Columns updated automatically after generate_complete

Column Source Description
ai_response_message_uuid your message_uuid Plenty message UUID to reply to
ai_response_content your content Draft reply body
ai_response_recipient your recipient (optional) Customer email
ai_response_reference your reference (optional) Subject / reference line
ai_response_whisper your whisper (optional) Whisper flag
ai_response_status your status or draft Draft state
ai_response_generated API sets true Draft exists
ai_response_sent API sets false Not sent yet
ai_processed_at API sets now When callback ran
status your ticket_status or IN_PROGRESS Ticket workflow status

Columns written on action: error

Column Value
ai_response_content error or content from payload
ai_response_status error
status IN_PROGRESS

Draft status values (UI / API)

ai_response_status Meaning
generating Dashboard triggered n8n, waiting for callback
draft Ready for human review
error Generation failed
sent Reply posted to Plenty (set by dashboard on send, not n8n)

After human review (dashboard only)

Humans edit via the UI. Optional explicit save:

PUT /api/v1/tickets/tickets/{ticket_uuid}/ai-response

Send (dashboard → Plenty, not n8n):

POST /api/v1/tickets/tickets/{ticket_uuid}/ai-response/send

On successful send the dashboard sets:

Column Value
ai_response_status sent
ai_response_sent true
ai_response_content Final sent text
response_sent_at Timestamp
status WAITING_FOR_RESPONSE

5. API reference (quick)

Base URL: /api/v1/tickets

Method Path Auth Purpose
POST /tickets/{uuid}/workflow/generate User JWT Trigger n8n
POST /webhooks/n8n X-API-KEY n8n callback
GET /tickets/{uuid}/ai-responses User JWT Read draft (0 or 1 item)
PUT /tickets/{uuid}/ai-response User JWT Update draft
POST /tickets/{uuid}/ai-response/send User JWT Send to Plenty
GET/PUT /tickets/workflow/settings User JWT n8n URL / token / callback

6. Example n8n workflow (node outline)

[Webhook]
    ↓
[IF action == "generate"]
    ↓
[Set] — extract ticket_uuid, message_uuid, callback_url, target entry from conversation (`is_target`)
    ↓
[OpenAI / AI Agent] — system prompt + conversation history from `conversation`
    ↓
[Set] — build callback JSON (action, content, recipient, reference)
    ↓
[HTTP Request] — POST to callback_url, header X-API-KEY
    ↓
[Respond to Webhook] — optional 200 to dashboard trigger

Error branch

[Error Trigger / Catch]
    ↓
[HTTP Request] — POST { callback_url, action: "error", ticket_uuid, error }

7. Testing the connection (before Save)

The dashboard Credentials → n8n Workflow tab has Test connection. It:

  1. Derives the test URL: https://host/webhook/{id}https://host/webhook-test/{id}
  2. POSTs a payload with action: test and a unique test_id
  3. Waits up to 30s for n8n to POST back to callback_url

Test webhook payload (dashboard → n8n)

{
  "action": "test",
  "test_id": "uuid-generated-by-dashboard",
  "callback_url": "https://your-dashboard/api/v1/tickets/webhooks/n8n",
  "message": "Dashboard n8n connection test"
}

Header: X-API-KEY: <your token> (same as production).

Test callback (n8n → dashboard)

{
  "action": "test_complete",
  "test_id": "<same test_id from inbound payload>"
}

Header: X-API-KEY: <same token>

No ticket or database fields are updated on test — only connectivity is verified.

Should n8n return HTTP 200 immediately?

Yes. Configure the Webhook node to respond immediately (HTTP 200), then continue the workflow asynchronously:

  1. Webhook receives request → respond 200 right away
  2. Workflow continues (AI, logic, etc.)
  3. HTTP Request node POSTs to callback_url when done

Production action: generate uses the same pattern: the dashboard only needs a quick acknowledgment from n8n; the draft arrives via the callback (generate_complete), not in the webhook response body.

n8n IF branch example

  • action === "test" → POST test_complete to callback_url (connection test)
  • action === "generate" → run AI → POST generate_complete to callback_url

8. Testing (manual)

  1. Configure webhook URL + token in Credentials → n8n Workflow.
  2. Set callback URL to a URL n8n can reach (not localhost unless tunneled).
  3. In n8n, use Listen for test event and trigger Generate via n8n on a ticket in the UI.
  4. Verify callback with curl:
curl -X POST "https://dashboard.example.com/api/v1/tickets/webhooks/n8n" \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: your-token" \
  -d '{
    "action": "generate_complete",
    "ticket_uuid": "YOUR-TICKET-UUID",
    "message_uuid": "YOUR-MESSAGE-UUID",
    "content": "Test draft from curl",
    "recipient": "customer@example.com",
    "reference": "RE: Test"
  }'
  1. Refresh the ticket in the UI — draft should appear for the matching message.
  2. Edit if needed → Send to Plenty (requires active Plenty credentials).

9. Troubleshooting

Symptom Check
UI stuck on "Generating..." n8n never called callback, or callback URL unreachable
401 on callback X-API-KEY mismatch with dashboard settings
400 message_uuid is required Callback missing message_uuid
400 content is required Callback missing content
404 Ticket not found Wrong ticket_uuid
Draft not visible on message message_uuid in callback must match the message row in UI
Send fails Plenty credentials / recipient empty — not an n8n issue

10. Closed-ticket training table (closed_ticket_message_pairs)

When a ticket is closed, the dashboard extracts minimal training rows:

Column Description
message_title Customer message subject
message_text Plain-text customer message (attachments as - Anhang: <file> lines; footer noise stripped)
ai_response Shop / AI reply text
ai_category Issue type (e.g. damaged_article, wrong_article)
closed_at When the ticket was closed

Example export for fine-tuning:

SELECT message_title, message_text, ai_response, ai_category, closed_at
FROM closed_ticket_message_pairs
ORDER BY closed_at DESC;

11. Per-message responses (active tickets)

The dashboard stores one row per inbound message in ticket_message_responses:

When What happens
Ticket refresh / load messages Plenty replies are paired to inbound messages and upserted (response_source=plenty)
n8n callback / dashboard draft Current draft is upserted for message_uuid (response_source=n8n or dashboard)
Close ticket (or Plenty closes on refresh) Pairs copied to closed_ticket_message_pairs; active rows marked archived=true

Each row stores: inbound message_text, outbound response_content, recipient, reference, status, source, and ai_category.

Humans can correct ai_category in the UI before close; the value is copied into closed_ticket_message_pairs.


File Role
app/api/v1/endpoints/tickets.py Generate trigger, send to Plenty
app/api/v1/endpoints/n8n_webhooks.py Callback handler
app/services/n8n_workflow.py Outbound webhook + config
app/services/ticket_ai_draft.py Ticket row ↔ draft mapping
app/services/message_response_store.py Per-message sync, archive, list
app/schemas/tickets.py Ticket model / DB columns
docker/postgres/migrations/add_ai_response_meta.sql AI draft columns migration
docker/postgres/migrations/add_ticket_ai_category.sql ai_category on tickets
docker/postgres/migrations/add_ticket_message_responses.sql Per-message response table (active tickets)
docker/postgres/migrations/add_closed_ticket_message_pairs.sql Closed-ticket training pairs table
app/services/closed_ticket_training.py Extract pairs on close