{"openapi":"3.1.0","info":{"title":"Spun Actions API","version":"1.0.0","summary":"Send WhatsApp messages and media, manage contacts and labels, and subscribe to real-time events.","description":"The Actions API is the public REST surface of Spun, built for automation platforms (n8n, Zapier, Make, Pabbly) and for CRMs calling Spun directly.\n\n### Authentication\nBearer API key (`wap_...`), created in Spun Settings > Webhooks > Manage API Keys. Each key carries an explicit scope list; each operation below documents the single scope it requires in `x-spun-scope`.\n\n### Rate limit\n60 requests per minute per API key, across all endpoints. Over the limit the API returns 429. `RateLimit-*` standard headers are sent on every response.\n\n### Idempotency\n`POST /send-message` and `POST /send-media` accept an optional `idempotency_key`. Retrying with the same key for the same org does not send a second message. The two endpoints differ in strength today: `/send-media` is backed by the Redis begin/resolve layer and returns 409 `send_outcome_unknown` when a previous attempt may have gone through, whereas `/send-message` only replays a key that already produced a stored message row, so a retry issued before the first attempt was recorded can still duplicate. Use a stable per-execution key on both.\n\n### Real-time events\nSubscribe with `POST /webhooks` (scope `manage_webhooks`) and Spun posts signed JSON to your URL. See the `webhooks` section for the envelope and the HMAC scheme.","contact":{"name":"Spun","url":"https://spun.com/support"},"license":{"name":"Proprietary","identifier":"LicenseRef-Spun-Proprietary"}},"servers":[{"url":"https://api.spun.com","description":"Production"}],"externalDocs":{"description":"n8n recipe and setup walkthrough","url":"https://spun.com/integrations/whatsapp-n8n"},"tags":[{"name":"Messages","description":"Send WhatsApp text and media, and poll message history."},{"name":"Contacts","description":"Create, update and read contacts."},{"name":"Labels","description":"Apply, remove and list labels."},{"name":"Phone","description":"Check whether a number is registered on WhatsApp."},{"name":"Webhooks","description":"Manage real-time event subscriptions."},{"name":"Meta","description":"Machine-readable description of this API."}],"security":[{"ApiKeyBearer":[]}],"paths":{"/api/integrations/openapi.json":{"get":{"tags":["Meta"],"operationId":"getOpenApiSpec","summary":"This document","description":"Returns this OpenAPI 3.1 description. Unauthenticated and not rate limited, so tooling can fetch it before the user has an API key.","security":[],"responses":{"200":{"description":"OpenAPI 3.1 document","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/integrations/send-message":{"post":{"tags":["Messages"],"operationId":"sendMessage","summary":"Send a WhatsApp text message","description":"Sends a text message from the org WhatsApp channel. If the channel is offline the message is queued and the response is 202 with a `queue_id` you can poll on `GET /queue/{id}`. Media sends behave differently: they are never queued.","x-spun-scope":"send_message","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["to","text"],"properties":{"to":{"type":"string","description":"Recipient phone number. E.164 with or without the leading `+`; anything else is stripped to digits and prefixed with `+`.","examples":["+972501234567","972501234567"]},"text":{"type":"string","description":"Message body."},"idempotency_key":{"type":"string","description":"Optional. Repeating a key that already produced a message returns the original result with `idempotent: true` instead of sending again."}}},"example":{"to":"+972501234567","text":"Hello! Thanks for contacting us."}}}},"responses":{"200":{"description":"Sent, or replayed from a previous call with the same `idempotency_key`.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","const":true},"message_id":{"type":"string"},"idempotent":{"type":"boolean","description":"Present and true only when this is a replay of an earlier send."}}},"example":{"success":true,"message_id":"ABC123"}}}},"202":{"description":"Channel offline. The message was queued and will send on reconnect.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","const":false},"queued":{"type":"boolean","const":true},"queue_id":{"type":"string","description":"Poll with GET /api/integrations/queue/{id}."},"error":{"type":"string"}}}}}},"400":{"description":"The `to` or `text` field is missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"to and text are required"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"},"502":{"description":"The WhatsApp provider rejected the send.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/integrations/send-media":{"post":{"tags":["Messages"],"operationId":"sendMedia","summary":"Send an image, video, audio, voice note or document","description":"Supply exactly one of `media_url` (fetched by Spun, guarded against private and metadata addresses) or `media_base64` (uploaded to Spun storage first).\n\nUnlike text, media is **not queued** when the channel is offline: the response is 409 `channel_offline` and the caller retries. The offline queue replays text only, so a queued media message would silently deliver as caption-only text.\n\nVoice notes sent as `media_base64` are converted to ogg/opus automatically. A voice note supplied as `media_url` is passed through unchanged, so it must already be ogg/opus to render as a WhatsApp voice note.","x-spun-scope":"send_message","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["type","to"],"properties":{"type":{"type":"string","enum":["image","video","audio","voice","document"]},"to":{"type":"string","description":"Recipient phone number."},"media_url":{"type":"string","format":"uri","description":"Publicly reachable URL. Mutually exclusive with `media_base64`."},"media_base64":{"type":"string","description":"Base64 payload, with or without a `data:` prefix. Max 25 MB decoded. Mutually exclusive with `media_url`."},"mime_type":{"type":"string","description":"MIME type for `media_base64` when the payload carries no data: prefix."},"caption":{"type":"string","description":"Caption. Honoured for image, video and document only."},"filename":{"type":"string","description":"Display filename, mainly for documents."},"idempotency_key":{"type":"string","description":"Optional, max 200 chars. Enforced through the Redis begin/resolve layer: a completed key replays the original 200, an in-flight or ambiguous key returns 409 `send_outcome_unknown` rather than risking a duplicate send."}}},"example":{"type":"image","to":"+972501234567","media_url":"https://example.com/receipt.png","caption":"Your receipt"}}}},"responses":{"200":{"description":"Sent, or replayed from a completed `idempotency_key`.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","const":true},"message_id":{"type":"string"}}}}}},"400":{"description":"Unknown `type`, missing `to`, neither or both media fields, or the media itself was rejected (`media_url_rejected` / `media_base64_rejected`, with a `reason`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"media_url_rejected","reason":"private address"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"`channel_offline` (media is never queued) or `send_outcome_unknown` (a previous attempt with this idempotency key may already have delivered).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"channel_offline","message":"WhatsApp channel is offline. Media messages are not queued - retry after the channel reconnects."}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"},"502":{"description":"Provider error, or `send_outcome_unknown` when the provider never answered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"No WhatsApp channel is configured for this org.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/integrations/queue/{id}":{"get":{"tags":["Messages"],"operationId":"getQueuedMessage","summary":"Poll a queued message","description":"Status of a message queued by a 202 from `POST /send-message`.","x-spun-scope":"send_message","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The `queue_id` from the 202 response."}],"responses":{"200":{"description":"Queue item","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","examples":["pending","sent","failed"]},"error_message":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"processed_at":{"type":["string","null"],"format":"date-time"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No such queue item for this org.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Queue item not found"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/integrations/messages":{"get":{"tags":["Messages"],"operationId":"listMessages","summary":"List messages (keyset pagination)","description":"Oldest-first page of the org message history, for polling triggers. Pass the `next_cursor` from the previous page back as `since` to advance. A page shorter than `limit` means you have caught up; keep the last `next_cursor` and poll again later.","x-spun-scope":"read_messages","parameters":[{"$ref":"#/components/parameters/Since"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"A page of messages","content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"}},"next_cursor":{"type":["string","null"]}}}}}},"400":{"description":"The `since` value is not a cursor from a previous page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"invalid_cursor","message":"since must be a cursor returned by a previous page"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/integrations/create-contact":{"post":{"tags":["Contacts"],"operationId":"createContact","summary":"Create or update a contact","description":"Upsert by phone number: supplying `phone_e164` updates the existing contact with that number instead of creating a duplicate, filling only the fields you send. Contacts without a phone number are allowed and are always created new.","x-spun-scope":"manage_contacts","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["full_name"],"properties":{"full_name":{"type":"string"},"phone_e164":{"type":"string","examples":["+972501234567"]},"email":{"type":"string","format":"email"},"profession":{"type":"string"}}}}}},"responses":{"201":{"description":"Created or updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","const":true},"contact_id":{"type":"string"}}}}}},"400":{"description":"The `full_name` field is missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"full_name is required"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/integrations/update-contact":{"patch":{"tags":["Contacts"],"operationId":"updateContact","summary":"Update contact fields","description":"Identify the contact by `contact_id` or by `phone_e164`. Only the listed fields can be written; unknown keys inside `fields` are ignored.","x-spun-scope":"manage_contacts","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["fields"],"properties":{"contact_id":{"type":"string"},"phone_e164":{"type":"string","description":"Used when `contact_id` is absent."},"fields":{"type":"object","description":"Any of the writable fields.","properties":{"full_name":{"type":"string"},"email":{"type":"string"},"profession":{"type":"string"},"phone_e164":{"type":"string"},"status":{"type":"string"},"preferred_language":{"type":"string"}}}}},"example":{"phone_e164":"+972501234567","fields":{"full_name":"Dana Cohen","profession":"Architect"}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Success"}}}},"400":{"description":"The `fields` object is missing, no identifier was supplied, or no writable field was present.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"No valid fields to update"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No contact with that phone number.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Contact not found"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/integrations/contacts":{"get":{"tags":["Contacts"],"operationId":"listContacts","summary":"Look up a contact, or list contacts","description":"Two modes on one path. Supplying `phone` or `id` looks that contact up and returns a `contacts` array of zero or one. Supplying neither returns an oldest-first keyset page with a `next_cursor`, the same pagination as `GET /messages`.","x-spun-scope":"read_contacts","parameters":[{"name":"phone","in":"query","schema":{"type":"string"},"description":"Exact E.164 lookup. Switches the endpoint out of list mode."},{"name":"id","in":"query","schema":{"type":"string"},"description":"Contact id lookup."},{"$ref":"#/components/parameters/Since"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"Lookup result, or a page of contacts (`next_cursor` is present in list mode only).","content":{"application/json":{"schema":{"type":"object","properties":{"contacts":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}},"next_cursor":{"type":["string","null"]}}}}}},"400":{"description":"The `since` value is not a cursor from a previous page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"invalid_cursor"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/integrations/apply-label":{"post":{"tags":["Labels"],"operationId":"applyLabel","summary":"Apply a label","description":"Identify the label by `label_id` or by `label_name` (case-insensitive; the label must already exist, this never creates one). Applying a label that is already applied succeeds with `created: false`.","x-spun-scope":"manage_labels","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["target_type","target_id"],"properties":{"label_id":{"type":"string"},"label_name":{"type":"string","description":"Used when `label_id` is absent."},"target_type":{"type":"string","enum":["chat","contact","group"]},"target_id":{"type":"string"}}},"example":{"label_name":"New Lead","target_type":"chat","target_id":"972501234567@s.whatsapp.net"}}}},"responses":{"200":{"description":"Applied. `created` is false when the label was already on the target.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","const":true},"created":{"type":"boolean"}}}}}},"400":{"description":"Missing target fields, an unknown `target_type`, or no label identifier.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No label with that name.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Label \"New Lead\" not found"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/integrations/remove-label":{"post":{"tags":["Labels"],"operationId":"removeLabel","summary":"Remove a label","description":"Requires `label_id`. Unlike apply-label this endpoint does not resolve a label name, so call `GET /labels` first if you only have the name. Removing a label that is not applied still returns 200.","x-spun-scope":"manage_labels","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["label_id","target_type","target_id"],"properties":{"label_id":{"type":"string"},"target_type":{"type":"string","enum":["chat","contact","group"]},"target_id":{"type":"string"}}}}}},"responses":{"200":{"description":"Removed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Success"}}}},"400":{"description":"A required field is missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"label_id, target_type, and target_id are required"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/integrations/labels":{"get":{"tags":["Labels"],"operationId":"listLabels","summary":"List labels","description":"All labels in the org, ordered by sort order then name. Also the cheapest call for verifying that a key works, which is what the n8n credential test uses.","x-spun-scope":"read_labels","responses":{"200":{"description":"Labels","content":{"application/json":{"schema":{"type":"object","properties":{"labels":{"type":"array","items":{"$ref":"#/components/schemas/Label"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/integrations/check-phone":{"post":{"tags":["Phone"],"operationId":"checkPhone","summary":"Check whether numbers are on WhatsApp","description":"Send one `phone` or up to 20 `phones`. Batching is strongly preferred: 20 numbers in one call costs one request against the 60/minute limit, looping costs 20.","x-spun-scope":"check_phone","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"phone":{"type":"string","description":"Single number. Non-digits are stripped."},"phones":{"type":"array","items":{"type":"string"},"maxItems":20}},"anyOf":[{"required":["phone"]},{"required":["phones"]}]},"example":{"phones":["+972501234567","+972521111111"]}}}},"responses":{"200":{"description":"One result per requested number, in request order.","content":{"application/json":{"schema":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"phone":{"type":"string","description":"Digits only, as normalised by Spun."},"is_whatsapp":{"type":"boolean"},"wa_id":{"type":["string","null"]}}}}}},"example":{"results":[{"phone":"972501234567","is_whatsapp":true,"wa_id":"972501234567@s.whatsapp.net"}]}}}},"400":{"description":"No number was supplied, or more than 20 were.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Maximum 20 phone numbers per request"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"},"502":{"description":"The WhatsApp provider returned an error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"No WhatsApp channel is configured, or the channel is not connected.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/integrations/webhooks":{"post":{"tags":["Webhooks"],"operationId":"createWebhookSubscription","summary":"Subscribe to events","description":"Creates a subscription and returns its signing secret **once**, in this response only. Store it: `GET /webhooks` never returns secrets and there is no way to read it back. The URL is checked against private, loopback and cloud-metadata addresses before it is accepted. See the `webhooks` section for the delivery envelope and signature scheme.","x-spun-scope":"manage_webhooks","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","events"],"properties":{"url":{"type":"string","format":"uri","description":"Public HTTPS endpoint that will receive deliveries."},"events":{"type":"array","minItems":1,"items":{"type":"string","enum":["message.inbound","message.outbound","contact.created","contact.updated","contact.deleted","label.assigned","chatflow.completed","queue.processed","lead.qualified","conversation.closed","call.completed","pipeline.stage_changed"]}},"name":{"type":"string","maxLength":120,"description":"Label shown in Spun Settings. Defaults to \"API subscription\"."}}},"example":{"url":"https://n8n.example.com/webhook/abc123","events":["message.inbound"],"name":"n8n production"}}}},"responses":{"201":{"description":"Created. The `secret` appears here and nowhere else.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookSubscription"},{"type":"object","properties":{"secret":{"type":"string","description":"HMAC-SHA256 signing secret, 64 hex chars. Returned only on create."},"_note":{"type":"string"}}}]}}}},"400":{"description":"Missing or unsafe `url`, or an unknown event name.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"get":{"tags":["Webhooks"],"operationId":"listWebhookSubscriptions","summary":"List subscriptions","description":"Every subscription for the org, including ones created in Spun Settings. Signing secrets are never included.","x-spun-scope":"manage_webhooks","responses":{"200":{"description":"Subscriptions","content":{"application/json":{"schema":{"type":"object","properties":{"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/WebhookSubscription"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/integrations/webhooks/{id}":{"delete":{"tags":["Webhooks"],"operationId":"deleteWebhookSubscription","summary":"Unsubscribe","description":"Deletes one subscription. Deliveries stop immediately.","x-spun-scope":"manage_webhooks","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Success"}}}},"400":{"description":"The id is not numeric.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Invalid webhook id"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No such subscription for this org.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Webhook not found"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}}},"webhooks":{"spunEvent":{"post":{"summary":"Event delivery from Spun to your URL","operationId":"receiveSpunEvent","description":"Spun POSTs this envelope to every active subscription whose event list contains the event.\n\n### Verifying the signature\n`X-Webhook-Signature` is `sha256=<hex>`, an HMAC-SHA256 of the **exact raw request body bytes** keyed with the subscription secret. Verify against the raw body, never against a re-serialisation of the parsed JSON: key order is not guaranteed to survive a parse and re-stringify, so a re-serialised comparison fails on valid deliveries and is not a real check. Compare in constant time. A subscription created without a secret (possible for subscriptions made in Spun Settings) is delivered unsigned, with no `X-Webhook-Signature` header at all.\n\n### Delivery semantics\nUp to 3 attempts: immediately, then after 5s, then after 30s, with a 10 second timeout per attempt. Any 2xx counts as delivered. Retries mean an event can arrive more than once, so treat `event_id` as the deduplication key. Ordering is not guaranteed.","parameters":[{"name":"X-Webhook-Signature","in":"header","required":false,"schema":{"type":"string","pattern":"^sha256=[0-9a-f]{64}$"},"description":"Present when the subscription has a signing secret. Format `sha256=<hex>`."},{"name":"X-Webhook-Event","in":"header","required":true,"schema":{"type":"string","enum":["message.inbound","message.outbound","contact.created","contact.updated","contact.deleted","label.assigned","chatflow.completed","queue.processed","lead.qualified","conversation.closed","call.completed","pipeline.stage_changed"]},"description":"The event name, also present in the body."},{"name":"X-Webhook-Delivery","in":"header","required":true,"schema":{"type":"string"},"description":"Matches `event_id` in the body. Stable across the retries of one event."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEnvelope"},"example":{"event_id":"550e8400-e29b-41d4-a716-446655440000","event":"message.inbound","timestamp":"2026-08-18T10:30:00.000Z","org_id":"123","data":{"message_id":"true_972501234567@s.whatsapp.net_ABC123","chat_id":"972501234567@s.whatsapp.net","from":"972501234567","type":"text","text":"Hello!"}}}}},"responses":{"200":{"description":"Any 2xx acknowledges the delivery. A non-2xx or a timeout triggers the retry schedule."}}}}},"components":{"securitySchemes":{"ApiKeyBearer":{"type":"http","scheme":"bearer","bearerFormat":"wap_<random>","description":"Org API key issued in Spun Settings > Webhooks > Manage API Keys. Send it as `Authorization: Bearer wap_...`. Every operation additionally requires a specific scope on the key (see `x-spun-scope`); a key missing the scope is rejected 403. Keys holding MCP-only scopes are structurally rejected on this surface: REST and MCP keys are separate by design. A key may also carry an IP allowlist, in which case requests from any other address are rejected 403 `ip_not_allowed`."}},"parameters":{"Since":{"name":"since","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor from a previous page `next_cursor`. Omit for the first page."},"Limit":{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"description":"Page size. Values above 100 are clamped to 100; invalid values fall back to 50."}},"responses":{"Unauthorized":{"description":"Missing, malformed, unknown, deactivated or expired API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Invalid API key"}}}},"Forbidden":{"description":"The key is valid but not permitted. `ip_not_allowed` means the caller address is not on the key IP allowlist (the configured list is deliberately not echoed back). A key holding MCP-only scopes is rejected on this surface entirely. Otherwise the key is missing the scope named in `x-spun-scope`, and the body names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"ipNotAllowed":{"summary":"Caller IP is not on the key allowlist","value":{"error":"ip_not_allowed"}},"missingScope":{"summary":"Key lacks the required scope","value":{"error":"API key lacks required scope: send_message","required_scope":"send_message","key_scopes":["read_labels"]}},"mcpKey":{"summary":"MCP key used on the REST surface","value":{"error":"This API key holds MCP scopes and cannot access /api/integrations. Create a separate key for legacy integrations."}}}}}},"RateLimited":{"description":"Over 60 requests per minute for this API key. `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` are on every response; back off until the reset.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Rate limit exceeded. Max 60 requests per minute per API key."}}}},"ServerError":{"description":"Unexpected server error. Safe to retry idempotent calls.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"schemas":{"Error":{"type":"object","description":"Every failure is a JSON object with an `error` string. It is a stable machine code on the errors that need one (`ip_not_allowed`, `channel_offline`, `send_outcome_unknown`, `invalid_cursor`, `media_url_rejected`, `media_base64_rejected`); elsewhere it is a human-readable sentence. Match on the documented codes, not on prose.","required":["error"],"properties":{"error":{"type":"string"},"message":{"type":"string","description":"Longer explanation, present on some errors."},"reason":{"type":"string","description":"Why the media was rejected."},"required_scope":{"type":"string"},"key_scopes":{"type":"array","items":{"type":"string"}}}},"Success":{"type":"object","properties":{"success":{"type":"boolean","const":true}}},"Contact":{"type":"object","properties":{"id":{"type":"string"},"full_name":{"type":["string","null"]},"phone_e164":{"type":["string","null"]},"email":{"type":["string","null"]},"profession":{"type":["string","null"]},"status":{"type":["string","null"]},"preferred_language":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}},"Message":{"type":"object","properties":{"id":{"type":"string"},"direction":{"type":"string","enum":["inbound","outbound"]},"message_type":{"type":["string","null"]},"text":{"type":["string","null"]},"whapi_message_id":{"type":["string","null"]},"status":{"type":["string","null"]},"conversation_id":{"type":"string"},"peer_phone_e164":{"type":["string","null"]},"peer_wa_id":{"type":["string","null"]},"is_group":{"type":["boolean","null"]},"group_subject":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"}}},"Label":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"color":{"type":["string","null"]},"sort_order":{"type":["integer","null"]},"created_at":{"type":"string","format":"date-time"}}},"WebhookSubscription":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string","enum":["message.inbound","message.outbound","contact.created","contact.updated","contact.deleted","label.assigned","chatflow.completed","queue.processed","lead.qualified","conversation.closed","call.completed","pipeline.stage_changed"]}},"is_active":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"}}},"WebhookEnvelope":{"type":"object","required":["event_id","event","timestamp","org_id","data"],"properties":{"event_id":{"type":"string","description":"Unique per event. Stable across retries, so use it to deduplicate."},"event":{"type":"string","enum":["message.inbound","message.outbound","contact.created","contact.updated","contact.deleted","label.assigned","chatflow.completed","queue.processed","lead.qualified","conversation.closed","call.completed","pipeline.stage_changed"]},"timestamp":{"type":"string","format":"date-time"},"org_id":{"type":"string"},"data":{"type":"object","description":"Event-specific payload. The shape varies by event."}}}}}}