Webhooks
A webhook is an address on your server that Sway calls with a POST when something happens: an order is paid, an event goes live, someone answers a form. Your site no longer has to ask the API every few minutes whether anything changed; it hears about it within seconds.
Webhooks are set up in the web app, not through the API: Crew admin → API → Webhooks. They come with the API, on the Studio and Roster plans, and the person setting them up needs the Manage API permission on the crew.
Add a webhook
- Open Crew admin → API, then the Webhooks tab, and click Add webhook.
- Enter the Endpoint URL: a public
httpsaddress on your server, for examplehttps://www.example.com/webhooks/sway. - Tick the events it should receive (see the list below).
- Leave Include personal data off unless the endpoint needs names and email addresses (see Personal data).
- Click Add webhook. The signing secret appears once, starting with
whsec_. Copy it into your server's configuration: Sway never shows it again.
Send yourself a call straight away with Send a test event: a webhook.test delivery arrives within seconds and shows up under Deliveries.
A crew can have up to 10 webhooks.
Addresses Sway will not call
The address must use https, name a host (not an IP address), and resolve to a public address when Sway sends the call. Sway refuses localhost, private and link-local networks, addresses with a user name or password in them, and ports below 1024 other than 443. Redirects are not followed: point the webhook at the final address.
To receive webhooks on a development machine, expose it through a tunnel that gives you a public https address.
The events
| Type | Sent when | Names a person |
|---|---|---|
order.paid | An order for one of your events is paid, on Sway or through your site | Yes: the buyer's email |
order.refunded | An order is refunded, in full or in part (one call per refund) | Yes: the buyer's email |
event.published | A published event becomes visible to your crew: it goes live, or one of your pages is added to it | No |
event.updated | An event you see changes: date, times, title, description, image, venue, line-up, status, or it is taken offline | No |
booking_request.created | A booking request arrives through POST /v1/booking-requests | Yes: the contact |
ambassador.sale_attributed | A paid sale is credited to one of your ambassadors | Yes: the ambassador's name |
form.submitted | Someone completes one of your forms | Yes: the answers and the contact |
payout.paid | A payout reaches the bank account of one of your pages | No |
Who receives what follows the same rules as the API reads:
- Orders and payouts go to the crews that manage the page the money goes to. A crew that only books an artist on the line-up does not hear about ticket sales.
- Events go to every crew whose pages are on the event: its promoters, its venue, its confirmed artists.
- Ambassador sales go to the crews that manage the programme's page.
- Forms go to the crew that owns the form, or that manages the page it belongs to.
Sway checks again just before sending. A crew that lost the page in between gets nothing.
event.published and event.updated
event.published is the call that tells your site to add an event. It is sent when an event is published, and also, once per webhook, when one of your pages is added to an event that is already published (the event just joined your crew). A new event's first call waits 30 seconds, so that the venue, the cover and the rest of the creation arrive in the same payload.
event.updated waits 60 seconds, and changes made in the meantime ride along: ten quick edits make one call. It is also sent when an event is taken offline; its payload then says "published": false and nothing else, so your site can remove the event.
When one of these calls makes your server read the API again (the event, a list it appears in), send those reads with Cache-Control: no-cache: Sway then reads them fresh instead of answering from a copy made before the change. See Caching.
What a call looks like
POST /webhooks/sway HTTP/1.1
Host: www.example.com
Content-Type: application/json
User-Agent: Sway-Webhooks/1.0 (+https://www.sway.events/docs/api)
webhook-id: msg_3b1f7a52-2c4e-4d8f-9a61-7e0c5d2b9f14
webhook-timestamp: 1759525447
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type":"order.paid","timestamp":"2026-10-03T21:04:07.000Z","data":{"order":{...}}}
The body is the Standard Webhooks envelope:
| Field | Meaning |
|---|---|
type | The event type, from the table above |
timestamp | When it happened, ISO 8601 in UTC |
data | The object, read from Sway when the call is sent, so it is always the current state |
webhook-id identifies the event. It stays the same on every retry and on a manual Send again: use it to ignore a call you already processed.
Payloads
Amounts are integers in the currency's smallest unit (cents), currencies are ISO 4217 codes.
order.paid and order.refunded
{
"type": "order.paid",
"timestamp": "2026-10-03T21:04:07.000Z",
"data": {
"order": {
"id": "0f5c2a8e-7b1d-4c3a-9e6f-2d8b4a1c7e53",
"event_id": 4521,
"status": "paid",
"currency": "EUR",
"amount_total": 4600,
"amount_refunded": 0,
"created_at": "2026-10-03T21:03:51.219+00:00",
"lines": [
{ "tier_id": "8a3e1f20-5c6d-4b7a-8e9f-1a2b3c4d5e6f", "name": "Early bird", "quantity": 2, "unit_amount": 2300 }
],
"ambassador_code": "LUCIA10",
"via_your_site": true,
"buyer": { "email": "[email protected]" }
}
}
}
via_your_site is true when the order went through a checkout your site opened with POST /v1/checkout-sessions. buyer is null on a webhook without personal data. On order.refunded, amount_refunded is the total refunded so far.
event.published and event.updated
{
"type": "event.published",
"timestamp": "2026-10-01T09:30:00.000Z",
"data": {
"event": {
"id": 4521,
"title": "Insomnia Night: Autumn Opening",
"description": "Ten hours, two rooms.",
"starts_at": "2026-10-03T20:00:00+00:00",
"ends_at": "2026-10-04T06:00:00+00:00",
"timezone": "Europe/Brussels",
"image_url": "https://cdn.sway.events/events/4521/cover.jpg",
"type": "Party",
"page_url": "https://www.sway.events/event/4521",
"venue": { "id": 77, "name": "Fuse" },
"published": true
}
}
}
The event is the summary the API returns in lists; fetch GET /v1/events/{id} for the line-up and the rest. An event taken offline:
{
"type": "event.updated",
"timestamp": "2026-10-02T14:12:00.000Z",
"data": { "event": { "id": 4521, "published": false } }
}
booking_request.created
{
"type": "booking_request.created",
"timestamp": "2026-10-02T11:45:10.000Z",
"data": {
"booking_request": {
"id": "5d2e9c41-3f7a-4b8e-a1c6-0e9f8d7c6b5a",
"artist_ids": [501],
"artist_names": ["Lucia Vega"],
"event_name": "Warehouse Series #4",
"event_date": "2026-12-12",
"venue": "Hangar 7",
"city": "Ghent",
"fee_offer": "2500 EUR",
"capacity": "800",
"ticket_price": "25 EUR",
"organization": "Nachtwerk vzw",
"status": "new",
"created_at": "2026-10-02T11:45:09.771+00:00",
"contact": { "name": "Jan Peeters", "email": "[email protected]", "phone": "+32470000000" }
}
}
}
ambassador.sale_attributed
{
"type": "ambassador.sale_attributed",
"timestamp": "2026-10-03T21:04:09.000Z",
"data": {
"sale": {
"order_id": "0f5c2a8e-7b1d-4c3a-9e6f-2d8b4a1c7e53",
"event_id": 4521,
"code": "LUCIA10",
"member_id": "c3a1e5b7-9d2f-4a6c-8e0b-1f3d5a7c9e2b",
"tickets": 2,
"amount_total": 4600,
"ambassador": { "first_name": "Lucia", "last_name": "Vega" }
}
}
}
form.submitted
{
"type": "form.submitted",
"timestamp": "2026-10-02T18:20:31.000Z",
"data": {
"response": {
"id": "e7b3c9d1-5a2f-4e8c-b6a0-9d1f3e5c7a2b",
"form": { "id": "1a9c7e5b-3d2f-4c8a-9e6b-0f2d4a6c8e1b", "slug": "guest-list", "title": "Guest list" },
"completed_at": "2026-10-02T18:20:30.412+00:00",
"locale": "en",
"answers": { "q_1": "Two guests", "q_2": ["Friday"] },
"contact": { "email": "[email protected]", "first_name": "Sam", "last_name": "Lee", "phone": null, "marketing_opt_in": true }
}
}
}
payout.paid
{
"type": "payout.paid",
"timestamp": "2026-10-06T08:00:12.000Z",
"data": {
"payout": {
"id": "9f1e3d5c-7b2a-4c6e-8a0d-2b4f6e8a1c3d",
"payee": { "type": "promoter", "id": 697 },
"amount": 120000,
"currency": "EUR",
"paid_at": "2026-10-06T07:59:58.103+00:00"
}
}
}
webhook.test, sent by Send a test event:
{
"type": "webhook.test",
"timestamp": "2026-10-01T10:00:00.000Z",
"data": { "message": "A test delivery from Sway. Nothing happened.", "endpoint_id": "2c7e9a1b-4d3f-4e8a-b5c6-7d9e1f3a5b7c" }
}
Personal data
Off by default, a call says what happened without saying who: buyer, contact, answers and ambassador are null. Turn Include personal data on and they carry the names, email addresses and answers.
Only the crew owner can turn it on, because it sends personal data out of Sway to a server Sway does not control. A member with the Manage API permission can turn it off. Whoever runs the receiving server becomes responsible for that data: store only what you need, and delete it when you no longer do.
Check the signature
Every call is signed with the webhook's secret, the Standard Webhooks way, so your server can refuse anything that did not come from Sway:
- Take the raw body, exactly as received. Parsing and re-serialising the JSON changes the bytes and breaks the signature.
- Build the string
{webhook-id}.{webhook-timestamp}.{body}. - Compute an HMAC-SHA256 of it, keyed with the secret's part after
whsec_, base64-decoded. - Compare it, base64-encoded, with each signature in
webhook-signature. The header is a space-separated list ofv1,{signature}; one match is enough. Use a constant-time comparison. - Refuse a
webhook-timestampmore than 5 minutes from your clock, so an intercepted call cannot be replayed later.
Sway sends every call with a Content-Length header. Most calls weigh a few kilobytes; a form answer with long texts can pass 1 MB. Refuse a call without that header, or one that declares more than the events you receive can weigh, before reading the body: the signature can only be checked once the body is read, and this keeps an unsigned caller from filling your server's memory.
Standard Webhooks publishes verifier libraries for many languages, JavaScript, Python, PHP, Go and Ruby among them (list). In Node.js:
npm install standardwebhooks
import express from 'express'
import { Webhook } from 'standardwebhooks'
const wh = new Webhook(process.env.SWAY_WEBHOOK_SECRET) // whsec_...
const app = express()
// express.raw keeps the body as bytes: the signature is over those bytes.
app.post('/webhooks/sway', express.raw({ type: 'application/json' }), (req, res) => {
let event
try {
event = wh.verify(req.body, req.headers)
}
catch {
return res.status(400).end()
}
res.status(204).end() // answer first, work after
queueSwayEvent(req.headers['webhook-id'], event)
})
The same check without a library, in Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifySway(rawBody, headers, secret) {
const id = headers['webhook-id']
const timestamp = Number(headers['webhook-timestamp'])
const signatures = headers['webhook-signature'] ?? ''
if (!id || !Number.isInteger(timestamp)) return false
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false
const key = Buffer.from(secret.slice('whsec_'.length), 'base64')
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest()
return signatures.split(' ').some((part) => {
const [version, signature] = part.split(',')
if (version !== 'v1' || !signature) return false
const given = Buffer.from(signature, 'base64')
return given.length === expected.length && timingSafeEqual(given, expected)
})
}
And in PHP, for a WordPress or plain PHP site:
<?php
$secret = getenv('SWAY_WEBHOOK_SECRET'); // whsec_...
$key = base64_decode(substr($secret, strlen('whsec_')));
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signatures = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
if ($id === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
$expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true));
$valid = false;
foreach (explode(' ', $signatures) as $part) {
[$version, $signature] = array_pad(explode(',', $part, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $signature)) {
$valid = true;
break;
}
}
if (!$valid) {
http_response_code(400);
exit;
}
http_response_code(204);
$event = json_decode($body, true);
// $event['type'], $event['data']: process it after answering.
Answer quickly, process after
Sway waits 10 seconds for an answer and reads only its status code. Any 2xx counts as delivered; anything else, or no answer at all, is a failure. Answer as soon as the signature checks out, then do the work (update a cache, write to a CRM, send an email) in the background.
Retries
A failed call is tried again after 5 seconds, 5 minutes, 30 minutes, then 2, 5, 10, 14, 20 and 24 hours: ten attempts over about three days. After the last one the delivery is marked Failed.
Calls are not guaranteed to arrive in order, and one can arrive twice (after a timeout on a call your server did process, for example). Make your handler idempotent: key your processing on webhook-id, and when the order of two calls matters, rely on the data (its status, its amounts) rather than on arrival order.
A webhook that keeps failing
A webhook whose calls have failed for three days without a single success is turned off, and the crew owner gets an email. The calls it still had waiting are kept. Fix the server, then click Turn on on the webhook: the failure clock starts again and the waiting calls go out. Events that happened while it was off were not queued for it: read the API once to catch up.
Deliveries and "Send again"
Deliveries on each webhook lists its latest 50 calls (calls are kept 30 days): the event, the state (Waiting, Sending, Delivered, Failed, Not sent), the number of tries and the last answer. The payload itself is not kept, since it is built at send time.
Send again puts a finished delivery back at the start of the schedule, with a payload built from the current state and the same webhook-id. Not sent means there was nothing to send any more: the object was deleted, or your crew no longer sees it.
Rotate the secret
New signing secret on a webhook creates a new secret and shows it once. For 24 hours every call carries two signatures in webhook-signature, one with the old secret and one with the new, so you can deploy the new secret without missing a call. After 24 hours the old secret stops signing.
Rotate whenever the secret may have leaked: a former contractor, a repository made public, a log that printed it.
Pause, edit, delete
- Pause stops the calls. Calls already waiting are kept and go out when you turn the webhook back on; events that happen during the pause are not queued for it.
- Edit changes the address, the events, the description and the personal data setting. The secret stays the same.
- Delete removes the webhook, its secret and its delivery history. It cannot be undone.