Webhooks
When something happens, Tickply sends a signed POST to the address you choose. Add the address in Settings → Technical → Integrations, under Notify another system, or with the webhooks scope, and choose its events. Up to 20 addresses per organizer.
The address must be public https, with no user name or password, and never an internal network. The whsec_ secret is shown when you create the address and when you change it; changing it stops the old one at once.
What we send
A POST with a JSON body:
{
"id": "4b6f6f4e-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"event": "order.paid",
"occurred_at": "2026-11-01T12:00:00Z",
"organization": { "id": 12, "slug": "your-organizer" },
"data": {
"item_id": 34,
"origin": "checkout",
"total_cents": 10000,
"currency": "BRL",
"subject": { "type": "order", "id": 56 }
}
}
data holds ids and simple values, never card data or personal data: use the ids to read the rest from the API. id is the event: one event sent to two addresses has the same id.
| Header | Content |
|---|---|
X-Tickply-Event |
The event name |
X-Tickply-Delivery |
The delivery id, the same on every attempt: use it to skip repeats |
X-Tickply-Timestamp |
Seconds since the epoch |
X-Tickply-Signature |
t=<timestamp>,v1=<HMAC-SHA256 of "<timestamp>.<body>" with the secret> |
User-Agent |
Tickply-Webhooks/1.0 |
Verifying the signature
Use the raw body, before parsing it, compare in constant time and reject anything older than 300 seconds.
Node:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, rawBody, header) {
const [, t, v1] = header.match(/^t=(\d+),v1=([a-f0-9]{64})$/) ?? [];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Python:
import hashlib, hmac, re, time
def verify(secret: str, raw_body: bytes, header: str) -> bool:
match = re.fullmatch(r"t=(\d+),v1=([a-f0-9]{64})", header)
if not match or abs(time.time() - int(match[1])) > 300:
return False
expected = hmac.new(secret.encode(), match[1].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, match[2])
PHP:
function verify(string $secret, string $rawBody, string $header): bool
{
if (preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $match) !== 1 || abs(time() - (int) $match[1]) > 300) {
return false;
}
return hash_equals(hash_hmac('sha256', $match[1].'.'.$rawBody, $secret), $match[2]);
}
Replying and retries
Answer any 2xx within 10 seconds (5 to connect), and do the slow work afterwards. Anything else is a failure, a redirect included: we do not follow it.
We try again after 1, 5, 30, 120, 360 and 720 minutes: up to 7 sends in all, with the same delivery id. The history, in Integrations or at GET /webhook-endpoints/{webhook}/deliveries, shows the reply of the last attempt, and any delivery can be sent once more by hand, with the same id.
After 20 failures in a row the address is paused (each attempt counts, automatic and manual retries included; any success starts the count again), and the owner is told. Nothing new is sent to a paused address. Resume it after fixing it, and send again by hand what you missed.
A webhook failure never undoes the sale, check-in or certificate that caused it. Deliveries are kept for 90 days.
Events
Choose the events of each address. GET /webhook-events lists them all, with available. Every body carries subject in data, plus the fields of its event.
-
order.paidsubject
orderA purchase was confirmed: at checkout, at the box office or as a complimentary order. origin says which.
item_idorigintotal_centscurrency -
order.canceledsubject
orderAn order was canceled: a complimentary one by the team, or a paid one by a refund or a fraud review.
item_idorigin -
order.expiredsubject
orderAn order expired without payment.
item_id -
participant.registeredsubject
participantA ticket or spot got a person: at purchase, in a complimentary order, or when a ticket bought "to be defined" got a name.
item_idorder_idticket_idoffer_id -
participant.transferredsubject
participantA ticket went to another person. The new ticket replaces the previous one.
item_idorder_idticket_idprevious_ticket_idoffer_id -
participant.checked_insubject
participantThe first check-in of a ticket on a day of the event; again only after it was undone.
item_idticket_idcheck_in_idsession_idmethod -
course.completedsubject
enrollmentA student completed a course.
item_idticket_idparticipant_id -
certificate.issuedsubject
certificateA certificate was issued, or corrected into a new version.
item_idticket_idtypeactivity_idcodeversion -
certificate.revokedsubject
certificateA certificate was revoked.
item_idticket_idtypeactivity_idcodeversion -
event.publishedsubject
eventAn event was published.
slug -
event.unpublishedsubject
eventAn event was taken off sale or archived.
slug -
interest.registeredsubject
interestSomeone confirmed their place on the interest list of an item.
item_id -
submission.decidedsubject
submissionThe result of a scientific submission was sent to its authors.
item_idnumberdecision
Arriving with upcoming features
Declared, listed with available: false, and not yet possible to choose. They start firing when the feature exists, and the changelog says when.
order.refunded: An order was refunded in full.order.partially_refunded: Part of an order was refunded.order.disputed: The buyer disputed the payment with the card issuer.order.dispute_closed: A payment dispute was closed.event.canceled: An event was canceled.payout.paid: A payout reached the organizer.seller.kyc_updated: The state of the payout account changed.