Webhooks
Register HTTPS endpoints for sale events in your workspace and verify the HMAC-SHA256 signature on each delivery.
A webhook is an HTTPS endpoint in your system that FreeTicket calls when something happens in your workspace. Managing webhooks requires the ADMIN role.
Events
| Event | Status |
|---|---|
sale.confirmed |
A sale was confirmed, either paid or recorded as comp/offline. |
sale.refunded |
Reserved: you can subscribe to it now, but it is not emitted yet. |
Register an endpoint
curl -X POST https://admin.appfreeticket.com/api/v1/webhooks \
-H "Authorization: Bearer ft_live_xxx" \
-H "X-Workspace-Id: <workspace-id>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/freeticket",
"events": ["sale.confirmed"]
}'
The response includes signingSecret, only this once. Store it in your secrets manager. You can pass your own secret in the body instead of letting FreeTicket generate one.
The URL must be http(s) and publicly reachable: localhost, private and link-local addresses are rejected. Use HTTPS in production.
With the CLI:
ft webhooks create --data '{"url":"https://example.com/hooks/freeticket","events":["sale.confirmed"]}'
ft webhooks list
ft webhooks delete <id>
PATCH /webhooks/{id} changes url, events or active without rotating the signing secret. To rotate it, create a new webhook and delete the old one.
Delivery format
Each delivery is a POST with a JSON body:
{
"event": "sale.confirmed",
"data": { "saleId": "…", "organizationId": "…", "reference": "…", "total": 80000 },
"sentAt": "2026-10-04T18:22:31.512Z"
}
data.organizationId is the workspace id. Send it as X-Workspace-Id when you fetch the full sale with GET /sales/{saleId}.
The request carries the header:
X-FreeTicket-Signature: <hex HMAC-SHA256 of the raw body, keyed with your signing secret>
Verify the signature
Compute the HMAC over the raw request body, before you parse it, and compare in constant time:
import { createHmac, timingSafeEqual } from "node:crypto";
export function isValidSignature(rawBody: string, header: string | null, secret: string) {
if (!header) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(header, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib, hmac
def is_valid_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)Reject the request with 401 when the signature doesn’t match.
Respond fast
- Return a
2xxwithin 5 seconds. Slower responses count as failures. Acknowledge first and do the work afterwards, for example in a queue. - Deliveries are best-effort and not retried. Treat the webhook as a fast signal and reconcile periodically against
GET /sales. - Make your handler idempotent on
data.saleId. GET /webhooksshowslastDeliveryAt,lastDeliveryStatusandfailureCount(consecutive failures) for each endpoint.

