Connect a Helpdesk to WhatsApp
This guide explains how to turn WhatsApp conversations in Spun into tickets in your helpdesk, and how to send agent replies from the helpdesk back to the customer on WhatsApp.
The connection runs through a small piece of middleware that you control, usually a workflow in n8n, Zapier or Make. Spun sends events to the middleware with outbound webhooks. The middleware calls your helpdesk's API, and sends replies back through the Spun Actions API. Every helpdesk needs the same five moves, so this page covers the shared building blocks first. The desk-by-desk notes are at the end.
What you need
- A Spun org with a connected WhatsApp line.
- A webhook subscription, created on the Webhooks page in the sidebar or through the API (see Webhooks).
- An API key with the right scopes, created from Webhooks → Manage API Keys (see Developer Portal).
- A middleware tool that can receive an HTTPS request and make API calls to your helpdesk.
- An API user or token for your helpdesk.
The generic flow: five moves
Every helpdesk integration does the same five things:
- Find or create the contact by phone. When a customer writes in, look up the requester in the helpdesk by phone number. Create them if they do not exist yet.
- Open a ticket. If the customer has no open ticket, create one. Store the Spun
chat_idon the ticket (a custom field or a tag works well) so later messages can find it. - Append each message. Add every new WhatsApp message to the open ticket as a comment or note.
- Send agent replies back. When an agent replies on the ticket, your helpdesk notifies the middleware, and the middleware sends the text to the customer with
POST /api/integrations/send-message. - Close. When the conversation is archived in Spun, the
conversation.closedevent lets the middleware close or resolve the ticket.
A few practical notes on these moves:
- Phone numbers. For a direct chat, the
chat_idlooks like[email protected]. Put a+in front of the digits before the@to get the E.164 number, here+15550142233. - Groups. Group chats end in
@g.us. Most support flows skip them. - Media. Message events carry the message
type(for exampleimageordocument) and the text. For media messages,textisnull. A simple approach is to add a note such as "Image received, open the conversation in Spun to view it". - After a close. If the customer writes again after the ticket was closed, Spun sends an ordinary
message.inbound. Your middleware decides whether to reopen the old ticket or open a new one, based on the ticket status it finds for thatchat_id. - Closing from the helpdesk. The Actions API does not close a Spun conversation today. If you want the Spun side to reflect a closed ticket, apply a label such as "Resolved" with
POST /api/integrations/apply-label. Create the label in Spun first.
The webhook envelope
Every delivery is an HTTPS POST with a JSON body in this shape:
{
"event_id": "8d2f4c1a-6b3e-4a9d-9f21-5c7e0b8a3d16",
"event": "message.inbound",
"timestamp": "2026-10-07T09:15:42.118Z",
"org_id": "42",
"data": { }
}| Field | Meaning |
|---|---|
event_id | A unique id for this delivery. It stays the same across the retries of that delivery. |
event | The event name, for example message.inbound. |
timestamp | When Spun sent the delivery, as an ISO 8601 time in UTC. |
org_id | Your Spun org id, as a string. |
data | The event fields, listed per event below. |
Each request carries these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Spun-Webhook/1.0 |
X-Webhook-Event | The event name, the same as event in the body. |
X-Webhook-Delivery | The delivery id, the same as event_id in the body. |
X-Webhook-Signature | sha256= followed by the hex HMAC of the body. Present only when the subscription has a signing secret. |
Payload templates
A subscription can reshape data with a payload template (raw, HubSpot, Salesforce, GoHighLevel, Pipedrive, Zapier). For a helpdesk flow, keep the template on raw. The other templates rename contact fields for a specific CRM, and the field names in this guide describe the raw shape.
Events and their fields
These are the events a subscription can listen to, with the data fields each one carries today.
message.inbound and message.outbound
message.inbound fires for each message a customer sends you. message.outbound fires for each message sent from your line, whether from the Spun inbox, a phone, or the Actions API.
| Field | Meaning |
|---|---|
message_id | The WhatsApp message id. |
chat_id | The chat id. @s.whatsapp.net for a direct chat, @g.us for a group. |
from | The sender id. |
from_me | true when the message was sent from your line. |
sender_name | The sender's display name, when known. |
chat_name | The chat's display name, when known. |
chat_archived | true when the chat is archived in Spun. |
integration_id | The id of the WhatsApp line that carried the message. Useful when an org has several lines. |
type | The message type, for example text, image, document, audio. |
text | The message text, or null for media messages. |
timestamp | When the message was sent, in Unix seconds. |
Message events are not enriched with contact details. Look the contact up by phone if the ticket needs a name or email.
conversation.closed
Fires after a chat is archived in Spun.
| Field | Meaning |
|---|---|
chat_id | The chat that was closed. |
reason | archived. |
closed_by | The email of the user who archived it, unknown, or mcp: followed by the key name when an AI assistant closed it. |
closed_at | When it was closed, as an ISO 8601 time. |
enriched | Contact details, see Enriched contact details. |
contact.created
Fires when a contact is added. The fields present depend on how the contact was created.
| Field | Meaning |
|---|---|
contact_id | The Spun contact id. Always present. |
source | How it was created: api, manual, auto_create, csv_import, mcp, or crm_details. Always present. |
full_name | The contact's name, when known. |
phone_e164 | The phone number in E.164 format, when known. |
email | The email address, when given. |
wa_lid | A WhatsApp linked id, on some automatically created contacts. |
fields_changed, updated_at | Present when the contact was created from the contact details panel. |
enriched | Contact details, when a phone number is available. |
contact.updated
| Field | Meaning |
|---|---|
contact_id | The Spun contact id. |
fields_changed | The list of field names that changed. |
source | api, manual, or crm_details. |
updated_at | When the change was saved. |
Read the current values with GET /api/integrations/contacts?id= when you need them.
contact.deleted
| Field | Meaning |
|---|---|
contact_id | The Spun contact id. |
action | deleted. |
label.assigned
| Field | Meaning |
|---|---|
label_id | The label id. |
target_type | chat, contact, or group. |
target_id | The chat id or contact id the label was applied to. |
assigned_by | Who applied it: a user, api, or rule_approved. |
source | manual or suggestion_approved. Not present when the label was applied through the API. |
assigned_at | When it was applied. |
original_chat_id | Present when the label was applied on a linked chat id. |
When autopilot marks a chat as do-not-send, the event carries label_name: "DONOTSEND", source: "autopilot", trigger_message and detected_language instead of a label_id.
lead.qualified
Fires when a label whose name is one of your org's lead labels is applied. The defaults are "lead", "hot lead" and "interested".
| Field | Meaning |
|---|---|
label_id, label_name | The label that qualified the lead. |
target_type, target_id | What the label was applied to. |
assigned_by | Who applied it. |
source | manual. |
delivery_key | A stable key for this qualification. |
original_chat_id | Present when applied on a linked chat id. |
enriched | Contact details. |
chatflow.completed
| Field | Meaning |
|---|---|
run_id | The chat flow run id. |
chat_id | The chat the flow ran in. |
flow_name, flow_type | Which flow ran. |
collected | The answers the flow collected. |
reason | flow_finished or max_messages_reached. |
queue.processed
Fires when a message that was queued (because the line was offline) is finally sent or fails.
| Field | Meaning |
|---|---|
queue_id | The id returned by send-message when it answered 202. |
chat_id | The destination chat. |
status | sent or failed. |
message_id | The WhatsApp message id, when sent. |
error | The reason, when failed. |
delivery_key | A stable key for this queue item. |
call.completed
| Field | Meaning |
|---|---|
call_id | The call id. |
chat_id, from, to | Who called whom. |
direction | inbound or outbound. |
call_type | The call type, voice unless WhatsApp reports another. |
status | How the call ended, for example missed, rejected or ended. |
duration_sec | The length of the call in seconds, or null when no duration was reported. |
timestamp | When the call happened, in Unix seconds. |
A single call can produce more than one call.completed as its status settles, for example a missed call that is later marked ended, so dedupe on call_id if you log calls on tickets.
pipeline.stage_changed
You can select this event on a subscription. Its deliveries come from a pipeline stage's own "send webhook" action, configured on the stage. A helpdesk flow does not need it.
Enriched contact details
lead.qualified, contact.created, contact.updated and conversation.closed gain an enriched object when Spun can work out a phone number from the event. It contains:
contact:id,full_name,phone_e164,email,whatsapp_name,preferred_language,status,tags,categories,created_at, ornullwhen no contact matches.labels: the labels on the chat, each withid,nameandcolor.conversation:first_message_at,last_message_atandmessage_count, ornull.donationsandenrollments: totals, for orgs that use those features.channel: line details, ornull.
Verify the signature
When the subscription has a signing secret, every request carries X-Webhook-Signature: sha256=<hex>. The hex value is the HMAC-SHA256 of the exact raw request body, keyed with the subscription's secret.
- A subscription created through the API gets a 64-character secret, returned once in the create response. Store it right away, it is never shown again.
- On the Webhooks page you can type your own secret, or use Rotate signing secret to generate a new one. New deliveries are signed with the new secret immediately, so update your receiver first.
To check a request:
- Read the raw body as bytes, before any JSON parsing. Parsing and re-serializing changes spacing and key order, and the signature will not match.
- Compute HMAC-SHA256 of the raw body with your secret, as lowercase hex.
- Compare it with the part of the header after
sha256=, using a constant-time comparison. - If they differ, answer
401and stop.
Worked example
With this example secret (do not reuse it):
3f9a6c1e8b2d4f7a0c5e9b1d3a6f8c2e4b7d0a9c1e3f5b8d2a4c6e8f0b1d3a5cand this raw body, sent as a single line:
{"event_id":"8d2f4c1a-6b3e-4a9d-9f21-5c7e0b8a3d16","event":"message.inbound","timestamp":"2026-10-07T09:15:42.118Z","org_id":"42","data":{"message_id":"3EB0A1F2C4D6E8B0","chat_id":"[email protected]","from":"15550142233","from_me":false,"sender_name":"Jordan Lee","chat_name":"Jordan Lee","chat_archived":false,"integration_id":"ABC123-XYZ45","type":"text","text":"Hi, my order has not arrived yet.","timestamp":1791364542}}the header is:
X-Webhook-Signature: sha256=229dba924798c69dd62327345fbd7c2fc3ccce032d7360bd3fa766b5f11f6b4eThe same check in Node.js:
const crypto = require('crypto');
function isValidSpunSignature(rawBody, header, secret) {
if (!header || !header.startsWith('sha256=')) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = header.slice('sha256='.length);
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}If your middleware cannot compute an HMAC, which is the case for some no-code tools, use a long random path in the webhook URL instead (for example https://hooks.example.com/spun/7f3c9e2a51d84b6f) and treat that URL as a secret.
Retries and timeouts
- Spun waits up to 10 seconds for each attempt.
- A
2xxresponse is a success. - A
4xxresponse is treated as permanent. Spun does not retry it. - A
5xxresponse, a timeout, or a network error is retried. Each delivery gets up to 3 attempts: right away, after 5 seconds, then after 30 seconds. - Deliveries are sent independently, so they can arrive out of order. Use
data.timestampto order messages on a ticket. - Spun checks the destination address on every delivery and refuses private or internal addresses. A refused delivery shows in the Delivery Log as
Blocked:followed by the reason.
Acknowledge first, then work
Creating a contact, finding a ticket and adding a comment can easily take more than 10 seconds when a helpdesk API is slow. If your receiver does all of that before it answers, Spun times out and retries, and the same message can be added to the ticket twice.
Answer 200 as soon as the signature checks out, then do the helpdesk calls afterwards. In n8n, set the Webhook node to respond immediately. In Zapier and Make, the trigger answers on its own. Answer 2xx for events your flow ignores too, so they are not retried.
Dedupe on event_id
Retries reuse the same event_id, the same body and the same signature. Keep a short list of the event_id values you have processed (a day is plenty) and skip any you have already seen.
Two things to know:
event_idis unique per subscription. If two subscriptions point at the same receiver, the same message arrives twice with two differentevent_idvalues. Use one subscription per receiver.- For message events, also dedupe on
data.message_id. That covers the rare case where the same WhatsApp message produces two separate deliveries.
Token metering and skipped deliveries
Each webhook delivery uses one token from your org's AI and automation token budget. Retries of the same delivery are included in that one token.
When the budget runs out, Spun skips deliveries instead of sending them. A skipped delivery is recorded in the webhook's Delivery Log with the error Skipped: AI token budget exhausted, next to the normal successes and failures. Nothing reaches your helpdesk while deliveries are skipped, so keep an eye on the budget for a support flow. You can add tokens at any time, see Token top-ups.
The Delivery Log is on the Webhooks page. It lists every attempt with its HTTP status, which is also the quickest way to debug a receiver.
Actions API endpoints for a helpdesk flow
The Actions API lives under https://api.spun.com/api/integrations. Authenticate with Authorization: Bearer <your API key>. Keys start with wap_.
Each operation needs one scope on the key. Give the key only the scopes your flow uses. A key can also carry an IP allowlist; requests from other addresses get 403 ip_not_allowed. Each key can make 60 requests per minute. The responses carry RateLimit-* headers, and a request over the limit gets 429.
The full contract is published as OpenAPI at https://api.spun.com/api/integrations/openapi.json. No key is needed to read it.
| Endpoint | Scope | Used for |
|---|---|---|
POST /send-message | send_message | Move 4: send an agent's text reply. Body: to, text, optional idempotency_key. |
POST /send-media | send_message | Send an image, video, audio, voice note or document. Body: type, to, media_url or media_base64 (up to 25 MB), optional mime_type, caption, filename, idempotency_key. |
GET /queue/{id} | send_message | Check a reply that was queued while the line was offline. |
GET /messages?since=&limit= | read_messages | Catch up on messages after an outage, oldest first, up to 100 per page. |
GET /contacts?phone= or ?id= | read_contacts | Move 1: read a Spun contact. |
POST /create-contact | manage_contacts | Create or update a contact by phone_e164 (with full_name). |
PATCH /update-contact | manage_contacts | Write helpdesk details back to the Spun contact. |
GET /labels | read_labels | List label ids and names. |
POST /apply-label, POST /remove-label | manage_labels | Mark a chat, for example "Resolved" after a close. Use label_id or label_name, and target_type chat, contact or group. |
POST /check-phone | check_phone | Check that a number is on WhatsApp before a first message. |
POST /webhooks, GET /webhooks, DELETE /webhooks/{id} | manage_webhooks | Let the middleware create and remove its own subscription. |
What send-message returns:
200withmessage_idwhen the message was sent. Keep this id, it is the key to loop guard 2 below.202withqueue_idwhen the line is offline. The message is sent when the line is back, andqueue.processedreports the result.400for a bad request,502when sending failed.
send-media is never queued. It answers 409 with channel_offline when the line is offline, so the middleware can try again after the line reconnects. It answers with send_outcome_unknown when Spun cannot tell whether the file reached the customer: 409 when an earlier attempt with the same idempotency_key is still unresolved, 502 when the send timed out. Check the conversation before sending that file again.
Loop guards
Without guards, a reply can loop: the agent replies in the helpdesk, the middleware sends it to WhatsApp, Spun reports the sent message as message.outbound, the middleware adds it to the ticket as a reply, and the helpdesk notifies the middleware again. Use all three guards.
- Echo as a private note, trigger on public replies only. Add
message.outboundevents to the ticket as private or internal notes. Set the helpdesk rule that calls your middleware to fire only on public agent replies. A note can then never trigger a send. - Skip your own sends. Store the
message_idfrom eachsend-messageresponse. When amessage.outboundarrives with that samemessage_id, it is the echo of your own reply, so skip it or only mark it as delivered. - One send per helpdesk comment. Pass the helpdesk's comment id as
idempotency_key, and also keep your own record of the comment ids you have already sent. Before sending, check that record, and send each comment once.
Helpdesks this track covers
All five follow the generic flow above. The middleware does the same work for each; what changes is where the phone number lives and how the helpdesk tells the middleware about an agent reply.
| Helpdesk | Where the customer lives | Where to store the phone and chat_id | How agent replies reach the middleware |
|---|---|---|---|
| Zoho Desk | Contacts | Contact phone field, ticket custom field for chat_id | A workflow rule or webhook on new public replies |
| Zendesk | Users (requesters) | User phone field, ticket tag or custom field for chat_id | A trigger that calls a webhook on public comments |
| Freshdesk | Contacts | Contact phone field, ticket custom field for chat_id | An automation rule with a webhook action on public replies |
| HubSpot Service Hub | Contacts, with the tickets object | Contact phone property, ticket property for chat_id | A workflow that sends a webhook when a reply is logged |
| Jira Service Management | Customers, with requests | A custom field for the phone and one for chat_id | An automation rule that sends a web request on public comments |
Webhook and automation features vary by helpdesk plan, so check that your plan includes them before you start. This is separate from Spun's HubSpot CRM contact sync, which keeps contacts in step and does not create tickets.
Troubleshooting
- Nothing arrives. Open the Delivery Log on the Webhooks page. Look for
Skipped: AI token budget exhausted,Blocked:entries, or HTTP errors from your receiver. - The signature never matches. Make sure you sign the raw body, not a parsed and re-serialized copy, and that the secret has no extra spaces.
- Messages appear twice on a ticket. Answer
200before doing the helpdesk work, and dedupe onevent_idandmessage_id. - Replies loop. Check that the helpdesk rule fires only on public replies and that echoes are added as private notes.