Skip to content

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:

  1. 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.
  2. Open a ticket. If the customer has no open ticket, create one. Store the Spun chat_id on the ticket (a custom field or a tag works well) so later messages can find it.
  3. Append each message. Add every new WhatsApp message to the open ticket as a comment or note.
  4. 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.
  5. Close. When the conversation is archived in Spun, the conversation.closed event lets the middleware close or resolve the ticket.

A few practical notes on these moves:

  • Phone numbers. For a direct chat, the chat_id looks 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 example image or document) and the text. For media messages, text is null. 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 that chat_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:

json
{
  "event_id": "8d2f4c1a-6b3e-4a9d-9f21-5c7e0b8a3d16",
  "event": "message.inbound",
  "timestamp": "2026-10-07T09:15:42.118Z",
  "org_id": "42",
  "data": { }
}
FieldMeaning
event_idA unique id for this delivery. It stays the same across the retries of that delivery.
eventThe event name, for example message.inbound.
timestampWhen Spun sent the delivery, as an ISO 8601 time in UTC.
org_idYour Spun org id, as a string.
dataThe event fields, listed per event below.

Each request carries these headers:

HeaderValue
Content-Typeapplication/json
User-AgentSpun-Webhook/1.0
X-Webhook-EventThe event name, the same as event in the body.
X-Webhook-DeliveryThe delivery id, the same as event_id in the body.
X-Webhook-Signaturesha256= 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.

FieldMeaning
message_idThe WhatsApp message id.
chat_idThe chat id. @s.whatsapp.net for a direct chat, @g.us for a group.
fromThe sender id.
from_metrue when the message was sent from your line.
sender_nameThe sender's display name, when known.
chat_nameThe chat's display name, when known.
chat_archivedtrue when the chat is archived in Spun.
integration_idThe id of the WhatsApp line that carried the message. Useful when an org has several lines.
typeThe message type, for example text, image, document, audio.
textThe message text, or null for media messages.
timestampWhen 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.

FieldMeaning
chat_idThe chat that was closed.
reasonarchived.
closed_byThe email of the user who archived it, unknown, or mcp: followed by the key name when an AI assistant closed it.
closed_atWhen it was closed, as an ISO 8601 time.
enrichedContact details, see Enriched contact details.

contact.created ​

Fires when a contact is added. The fields present depend on how the contact was created.

FieldMeaning
contact_idThe Spun contact id. Always present.
sourceHow it was created: api, manual, auto_create, csv_import, mcp, or crm_details. Always present.
full_nameThe contact's name, when known.
phone_e164The phone number in E.164 format, when known.
emailThe email address, when given.
wa_lidA WhatsApp linked id, on some automatically created contacts.
fields_changed, updated_atPresent when the contact was created from the contact details panel.
enrichedContact details, when a phone number is available.

contact.updated ​

FieldMeaning
contact_idThe Spun contact id.
fields_changedThe list of field names that changed.
sourceapi, manual, or crm_details.
updated_atWhen the change was saved.

Read the current values with GET /api/integrations/contacts?id= when you need them.

contact.deleted ​

FieldMeaning
contact_idThe Spun contact id.
actiondeleted.

label.assigned ​

FieldMeaning
label_idThe label id.
target_typechat, contact, or group.
target_idThe chat id or contact id the label was applied to.
assigned_byWho applied it: a user, api, or rule_approved.
sourcemanual or suggestion_approved. Not present when the label was applied through the API.
assigned_atWhen it was applied.
original_chat_idPresent 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".

FieldMeaning
label_id, label_nameThe label that qualified the lead.
target_type, target_idWhat the label was applied to.
assigned_byWho applied it.
sourcemanual.
delivery_keyA stable key for this qualification.
original_chat_idPresent when applied on a linked chat id.
enrichedContact details.

chatflow.completed ​

FieldMeaning
run_idThe chat flow run id.
chat_idThe chat the flow ran in.
flow_name, flow_typeWhich flow ran.
collectedThe answers the flow collected.
reasonflow_finished or max_messages_reached.

queue.processed ​

Fires when a message that was queued (because the line was offline) is finally sent or fails.

FieldMeaning
queue_idThe id returned by send-message when it answered 202.
chat_idThe destination chat.
statussent or failed.
message_idThe WhatsApp message id, when sent.
errorThe reason, when failed.
delivery_keyA stable key for this queue item.

call.completed ​

FieldMeaning
call_idThe call id.
chat_id, from, toWho called whom.
directioninbound or outbound.
call_typeThe call type, voice unless WhatsApp reports another.
statusHow the call ended, for example missed, rejected or ended.
duration_secThe length of the call in seconds, or null when no duration was reported.
timestampWhen 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, or null when no contact matches.
  • labels: the labels on the chat, each with id, name and color.
  • conversation: first_message_at, last_message_at and message_count, or null.
  • donations and enrollments: totals, for orgs that use those features.
  • channel: line details, or null.

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:

  1. 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.
  2. Compute HMAC-SHA256 of the raw body with your secret, as lowercase hex.
  3. Compare it with the part of the header after sha256=, using a constant-time comparison.
  4. If they differ, answer 401 and stop.

Worked example ​

With this example secret (do not reuse it):

3f9a6c1e8b2d4f7a0c5e9b1d3a6f8c2e4b7d0a9c1e3f5b8d2a4c6e8f0b1d3a5c

and this raw body, sent as a single line:

json
{"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=229dba924798c69dd62327345fbd7c2fc3ccce032d7360bd3fa766b5f11f6b4e

The same check in Node.js:

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 2xx response is a success.
  • A 4xx response is treated as permanent. Spun does not retry it.
  • A 5xx response, 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.timestamp to 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_id is unique per subscription. If two subscriptions point at the same receiver, the same message arrives twice with two different event_id values. 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.

EndpointScopeUsed for
POST /send-messagesend_messageMove 4: send an agent's text reply. Body: to, text, optional idempotency_key.
POST /send-mediasend_messageSend 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_messageCheck a reply that was queued while the line was offline.
GET /messages?since=&limit=read_messagesCatch up on messages after an outage, oldest first, up to 100 per page.
GET /contacts?phone= or ?id=read_contactsMove 1: read a Spun contact.
POST /create-contactmanage_contactsCreate or update a contact by phone_e164 (with full_name).
PATCH /update-contactmanage_contactsWrite helpdesk details back to the Spun contact.
GET /labelsread_labelsList label ids and names.
POST /apply-label, POST /remove-labelmanage_labelsMark a chat, for example "Resolved" after a close. Use label_id or label_name, and target_type chat, contact or group.
POST /check-phonecheck_phoneCheck that a number is on WhatsApp before a first message.
POST /webhooks, GET /webhooks, DELETE /webhooks/{id}manage_webhooksLet the middleware create and remove its own subscription.

What send-message returns:

  • 200 with message_id when the message was sent. Keep this id, it is the key to loop guard 2 below.
  • 202 with queue_id when the line is offline. The message is sent when the line is back, and queue.processed reports the result.
  • 400 for a bad request, 502 when 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.

  1. Echo as a private note, trigger on public replies only. Add message.outbound events 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.
  2. Skip your own sends. Store the message_id from each send-message response. When a message.outbound arrives with that same message_id, it is the echo of your own reply, so skip it or only mark it as delivered.
  3. 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.

HelpdeskWhere the customer livesWhere to store the phone and chat_idHow agent replies reach the middleware
Zoho DeskContactsContact phone field, ticket custom field for chat_idA workflow rule or webhook on new public replies
ZendeskUsers (requesters)User phone field, ticket tag or custom field for chat_idA trigger that calls a webhook on public comments
FreshdeskContactsContact phone field, ticket custom field for chat_idAn automation rule with a webhook action on public replies
HubSpot Service HubContacts, with the tickets objectContact phone property, ticket property for chat_idA workflow that sends a webhook when a reply is logged
Jira Service ManagementCustomers, with requestsA custom field for the phone and one for chat_idAn 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 200 before doing the helpdesk work, and dedupe on event_id and message_id.
  • Replies loop. Check that the helpdesk rule fires only on public replies and that echoes are added as private notes.
Was this page helpful?

Spun Docs - the documentation for Spun, the WhatsApp team inbox by Spun Life LLC.