Webhooks API

Get a signed POST to your own URL the moment something happens in your organisation, instead of polling. Covers subscribing, the event envelope, signature verification, retries and the event catalogue.

A webhook lets Seaty tell your system the moment something happens, instead of you polling the API. You register an https URL, and Seaty sends it a signed POST for each event you subscribe to.

Subscribing

Webhooks are managed in the admin dashboard, not via the API. Go to your organisation's Developer tools, open the Webhooks tab, and:

  1. Enter your endpoint URL (must be https://).
  2. Tick the events you want.
  3. Save. A signing secret (whsec_...) is shown, you will use it to verify deliveries. You can read it again from the list at any time.

You can pause (disable), edit, delete, and send a test ping to a webhook from the same screen. The delivery log shows recent attempts with their status and response code; open any delivery to inspect the exact payload that was sent and the response your endpoint returned, and to resend it. For successful deliveries the request and response bodies are not retained (only failed or retrying deliveries keep them, for diagnosis and resend), so a delivered row shows no body.

Your endpoint must be publicly reachable. The URL has to be https:// and resolve to a public internet address. Seaty refuses to deliver to localhost, private/internal addresses, or cloud-metadata hosts: such attempts are recorded as blocked in the delivery log and never sent. Test with a public tunnel (for example an ngrok https URL) rather than an internal hostname.

The delivery

Every delivery is an HTTP POST with a JSON body in this envelope:

{
  "id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "type": "balance.payment_recorded",
  "organisation_id": 24,
  "organisation_tag": "wmtc",
  "event_id": 1024,
  "event_tag": "spring-show",
  "created": "2026-06-04T12:00:00Z",
  "data": {
    "id": 90233,
    "guid": "f1c2...",
    "event_id": 1024,
    "payee_email": "buyer@example.com",
    "amount": 1500,
    "type_id": 1,
    "is_refund": false,
    "date_payment_made": "2026-06-04T00:00:00Z",
    "notes": "Cash on the door"
  }
}

The envelope always carries the context of who and what the event is about, so you do not have to dig into data to route it:

FieldMeaning
organisation_id / organisation_tagThe organisation the event belongs to (numeric id and the URL tag).
event_id / event_tagThe Seaty event (the show/production) the event relates to, when applicable. Both are null for events not tied to a single event (and for the test ping).

The data object matches the shape of the matching read endpoint (so a balance.payment_recorded payload looks like a payment from an order's payments). Alongside the body, these headers are sent:

HeaderMeaning
X-Seaty-EventThe event type, e.g. balance.payment_recorded.
X-Seaty-DeliveryA unique id for this delivery attempt.
X-Seaty-Signaturet=<unix-timestamp>,v1=<hmac> (see below).

Verifying the signature

Always verify. Compute an HMAC-SHA256 over "<timestamp>.<raw request body>" using your signing secret, and compare it to the v1 value in the X-Seaty-Signature header. Use the raw body bytes exactly as received, before any JSON parsing.

import hashlib
import hmac

def is_valid(secret: str, signature_header: str, raw_body: str) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    expected = hmac.new(
        secret.encode(),
        f"{parts['t']}.{raw_body}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Reject the request if it does not match. Two things make this robust:

  • Use a constant-time comparison (e.g. hmac.compare_digest, as above), never ==, so you do not leak the signature through timing.
  • Reject stale timestamps to limit replay. Treat the delivery as invalid if t is more than 5 minutes from your own clock (abs(now - t) > 300). The t value is the unix timestamp in the X-Seaty-Signature header. Combined with deduplicating on the envelope id, this stops a captured payload from being replayed against you later.

Responding, retries and ordering

  • Return any 2xx quickly (within 10 seconds) to acknowledge receipt. Do the real work afterwards.
  • Any non-2xx, a timeout, or a connection error is treated as a failure and retried with increasing backoff (about 1 min, 5 min, 30 min, then 2 hours, up to 5 attempts) before the delivery is marked failed. Redirects are not followed (a 3xx counts as a failure), so point the webhook at its final URL.
  • Delivery is at least once: a retry can arrive after you already processed one. The envelope id is a unique id per event (not the resource id, which is in data) and stays the same across retries of that event, so deduplicate on id.
  • Order is not guaranteed. Use created if you need to reason about sequence.

When an endpoint keeps failing

If deliveries to an endpoint keep failing, Seaty stops wasting attempts on it:

  • After about 20 consecutive failed deliveries, or an endpoint that has been failing continuously for roughly 24 hours, the endpoint is automatically disabled and the organisation's administrators are emailed. No further events are sent to it until it is re-enabled.
  • Fix the endpoint (make sure it is publicly reachable and returns a 2xx), then re-enable it from the dashboard. Re-enabling clears the failure count and resumes delivery. Events that fired while it was disabled are not back-filled.
  • Separately, Seaty can suspend all webhooks for an organisation as a rare anti-abuse measure. While suspended, deliveries stop and creating or testing a webhook returns an error asking you to contact support@seaty.co.uk.

Event catalogue

Event typeFires whendata
balance.payment_recordedAn external balance payment is recorded against an event.The payment (see Balance payments). amount is always positive pence; is_refund gives the direction.
balance.refund_recordedAn external balance refund is recorded against an event.The refund (same shape, is_refund: true, amount still positive pence).
order.placedAn order is created: a customer purchase, an admin or comp order, or an accepted ticket request. is_admin_order is true for orders placed by an organiser in the admin tools.id, order_guid, event_date_id, email, attendee_name, ticket_count, tickets (the per-ticket list, see below), is_admin_order, and the money breakdown below (all pence).
ticket.checked_inA ticket is checked in (scanned and admitted) for the first time. Re-scans of an already-admitted ticket do not fire.ticket_guid, order_id, event_date_id, checked_in_at.
request.madeA customer makes a ticket request that is awaiting approval.id, event_date_id, email, status, ticket_count, subtotal, discount (an object, or null when there is no discount), discount_total, total (pence). A request is pre-approval, so it has no fees and nothing is paid yet.
order.cancelledTickets on an order are cancelled in the admin tools.id, order_guid, event_date_id, email, reason, cancelled_by_user_id, ticket_count, tickets (the cancelled tickets: ticket_guid, ticket_name, ticket_group, ticket_category_id, ticket_category_name, seat_category_id), and the refund summary below.
order.refundedA refund is issued against an order while cancelling tickets. Fires alongside order.cancelled when that cancellation includes a refund.order_id, order_guid, event_date_id, email, amount (positive pence), type_id (payment method, see Reference), reason, refunded_by_user_id, ticket_count.
request.declinedAn organiser declines a pending ticket request.id, event_date_id, email, status ("declined"), ticket_count, refused_reason.
request.approvedAn organiser approves a pending ticket request, turning it into an order. Because an approval creates an order, order.placed also fires for the same action (linked by order_id). An auto-accepted request (for example a trusted member booking directly) emits request.approved and order.placed but no preceding request.made.id, event_date_id, email, status ("approved"), ticket_count, order_id, order_guid.
request.cancelledA customer withdraws their own pending request before it is approved or declined.id, event_date_id, email, status ("cancelled"), ticket_count.
order.movedA booking is moved to another event date, either by moving the order or by swapping its tickets to a new date.order_id, order_guid, reason ("move" or "swap"), from_event_date_id, to_event_date_id, moved_by_user_id.
order.transferredAn order's ownership is transferred to a different email address.order_id, order_guid, event_date_id, old_email, new_email, transferred_by_user_id.
subscriber.unsubscribedAn organiser unsubscribes an email address from marketing or survey emails.email, category (the email preference category, or null for all), event_id (or null for organisation-wide), reason, unsubscribed_by_user_id, preferences_updated, orders_updated.

order.placed money breakdown

All amounts are integer pence.

FieldMeaning
subtotalTicket face value, before discount.
discounts[]Each applied discount: name, code, amount. An empty array [] when there are none.
discount_totalSum of discounts[].amount.
feeshandling, service (Seaty/card fee) and organisation fee amounts. Populated on the card-payment path; 0 for comp/admin orders.
fee_modeabsorbed (the organiser pays the service and organisation fees, so the customer is not charged them) or passed_on (the customer pays them on top).
donationOptional donation added to the order.
enhanced_refund_feeRefund-protection fee, if the customer took it.
totalWhat the customer was charged: subtotal - discount_total + donation + handling + enhanced_refund_fee, plus service + organisation when fee_mode is passed_on.

order.placed fires when the order is created, which is just before the payment row is written. For the authoritative settled figures (amount_paid, amount_refunded, balance, the full payments list), read the order through the Orders API.

order.placed tickets

tickets is the list of individual tickets the order created, one entry per ticket. A ticket can be a seated ticket, a general-admission ticket, or a piece of scannable merchandise; they are all listed the same way. ticket_count is the length of this list.

FieldMeaning
ticket_guidThe ticket's unique, scannable reference: the same guid Seaty uses to build the ticket's QR/scan URL. Treat it as sensitive (see below).
ticket_nameThe ticket/seat name (a seat number for seated tickets, or a sequential number for general admission).
ticket_groupThe seating group or block the ticket belongs to.
ticket_category_idThe ticket category id. Resolve names and prices via ticket categories.
ticket_category_nameThe ticket category name (for example "Adult"), when available.
seat_category_idThe seat category id, from your seating plan's seat categories. Set for seated tickets; null for general admission and merchandise.
seat_category_nameThe seat category the seat belongs to, from your seating plan's seat categories (for example "Premium"). Set for seated tickets; null for general admission and merchandise.
section_nameThe name of the seating plan the ticket sits in. Useful when an event uses more than one seating plan. Set for seated tickets; null for general admission and merchandise.

For a seated ticket you get both the seat category (seat_category_name) and the seating plan it sits in (section_name); general-admission and merchandise tickets leave both null. The check-in fields shown by the Orders API tickets endpoint are omitted here, since a freshly placed order has not been scanned yet.

ticket_guid is a scannable credential. It is the value that forms a ticket's QR/scan URL, so anyone who holds it could present a valid-looking ticket. Seaty only ever sends it as a signed, HTTPS delivery to the endpoint you configured, and never retains the request body of a successful delivery. Even so, handle these guids as carefully as the tickets themselves: keep them server-side, do not log them in the clear, and do not forward them to untrusted parties. If you only need to reconcile or count orders and do not need to admit ticket-holders, you can simply ignore the tickets array.

Cancellations and refunds

order.cancelled fires whenever tickets on an order are cancelled in the admin tools. Its tickets array lists each cancelled ticket (ticket_guid, ticket_name, ticket_group, ticket_category_id, ticket_category_name, seat_category_id) so you can reconcile exactly which tickets, by ticket_guid, are no longer valid. (It is the order.placed ticket shape without the seat_category_name and section_name fields.) It also carries a refund summary: refunded (boolean), refund_amount (positive pence) and refund_type_id (the payment method).

When that cancellation includes a refund, a separate order.refunded also fires, so an integration that only cares about money movement can subscribe to order.refunded alone. A cancellation with no refund fires only order.cancelled.

Scope to be aware of: order.refunded currently covers refunds issued while cancelling tickets (a card refund through Seaty, type_id 5, or a manually recorded refund). Refunds recorded off-platform through the API arrive as balance.refund_recorded instead, and a partial refund that does not cancel any tickets is not yet covered.

Resale appears as a cancel-and-rebook. When a ticket is resold, the original holder's tickets are cancelled (you receive order.cancelled, with reason mentioning resale) and a new order is created for the buyer (order.placed). If you automatically act on order.cancelled (for example issuing your own refund), inspect reason so you do not treat a resold seat as a plain cancellation.

A ping event is also delivered when you use Send test in the dashboard, so you can check your signature handling before going live.

More event types (order updates, request approvals, and others) will be added to this catalogue as they come online. Subscribing only ever delivers the events that exist.


Need help? Email support@seaty.co.uk.