Webhooks API
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:
- Enter your endpoint URL (must be
https://). - Tick the events you want.
- 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 tolocalhost, 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:
| Field | Meaning |
|---|---|
organisation_id / organisation_tag | The organisation the event belongs to (numeric id and the URL tag). |
event_id / event_tag | The 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:
| Header | Meaning |
|---|---|
X-Seaty-Event | The event type, e.g. balance.payment_recorded. |
X-Seaty-Delivery | A unique id for this delivery attempt. |
X-Seaty-Signature | t=<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
tis more than 5 minutes from your own clock (abs(now - t) > 300). Thetvalue is the unix timestamp in theX-Seaty-Signatureheader. Combined with deduplicating on the envelopeid, 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
3xxcounts 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
idis a unique id per event (not the resource id, which is indata) and stays the same across retries of that event, so deduplicate onid. - Order is not guaranteed. Use
createdif 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 type | Fires when | data |
|---|---|---|
balance.payment_recorded | An 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_recorded | An external balance refund is recorded against an event. | The refund (same shape, is_refund: true, amount still positive pence). |
order.placed | An 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, is_admin_order, and the money breakdown below (all pence). |
ticket.checked_in | A 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.made | A 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.placed money breakdown
All amounts are integer pence.
| Field | Meaning |
|---|---|
subtotal | Ticket face value, before discount. |
discounts[] | Each applied discount: name, code, amount. An empty array [] when there are none. |
discount_total | Sum of discounts[].amount. |
fees | handling, service (Seaty/card fee) and organisation fee amounts. Populated on the card-payment path; 0 for comp/admin orders. |
fee_mode | absorbed (the organiser pays the service and organisation fees, so the customer is not charged them) or passed_on (the customer pays them on top). |
donation | Optional donation added to the order. |
enhanced_refund_fee | Refund-protection fee, if the customer took it. |
total | What 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.
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.