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¶
| 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_categorieslist contains 12 keys — seeticket_issue_categories.pyin 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, rawmessageswith full HTML bodies, duplicateticket.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¶
- Webhook node — receive the dashboard payload (
action = generate). - Build prompt — use
ticket,conversation(findis_target: true), and each entry'sai_responsefor prior replies. - Generate reply — OpenAI, Anthropic, template engine, or rules.
- HTTP Request node — POST result to
callback_url(see below).
Recommended extras¶
- Set
recipientfromticket.referrer_valueorticket.from_valueif the customer email is known. - Set
referenceto something likeRE: {ticket.title}. - Set
whispertofalsefor normal customer replies (trueonly for internal notes). - Classify the issue and set
ai_categoryto one key fromissue_categoriesin the webhook. - On failure, POST with
action: "error"and anerrormessage.
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_value → from_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:
- Derives the test URL:
https://host/webhook/{id}→https://host/webhook-test/{id} - POSTs a payload with
action: testand a uniquetest_id - 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:
- Webhook receives request → respond 200 right away
- Workflow continues (AI, logic, etc.)
- HTTP Request node POSTs to
callback_urlwhen 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"→ POSTtest_completetocallback_url(connection test)action === "generate"→ run AI → POSTgenerate_completetocallback_url
8. Testing (manual)¶
- Configure webhook URL + token in Credentials → n8n Workflow.
- Set callback URL to a URL n8n can reach (not
localhostunless tunneled). - In n8n, use Listen for test event and trigger Generate via n8n on a ticket in the UI.
- 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"
}'
- Refresh the ticket in the UI — draft should appear for the matching message.
- 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.
Related files (implementation)¶
| 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 |