Webhooks¶
A webhook tells your server when something changes, so you do not have to keep asking. It works for the rows you can read with your key: orders, reservations and guests.
A webhook says something changed. It does not replace the sync: your first load comes from the list routes, and they are also how you catch up after your server was down.
Add an endpoint¶
Your key needs the permission webhooks:manage, and the permission that reads the rows: orders:read for order
and ticket events, reservations:read for reservation events, guests:read for guest events. order.refunded
also needs the field group orders:payments.
curl -X POST "https://production.speakeasygo.com/external/v1/webhook-endpoints" \
-H "token: $SPEAKEASY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/speakeasy/webhooks",
"eventTypes": ["order.confirmed", "order.refunded", "guest.opted_out"]
}'
{
"id": "88888888-8888-4888-8888-888888888888",
"url": "https://example.com/speakeasy/webhooks",
"eventTypes": ["order.confirmed", "order.refunded", "guest.opted_out"],
"status": "ENABLED",
"disabledReason": null,
"secret": "whsec_3q2-7wEXAMPLE",
"createdAt": "2026-11-05T10:00:00.000Z",
"updatedAt": "2026-11-05T10:00:00.000Z"
}
Save secret now. We show it only in this answer. Events start from this moment: nothing from before goes
out. The same goes for a venue added to your key later, or an event type you add later: they start from then, so
use the list routes for what came before.
The address must use https on port 443, with a host name on a public address. We do not follow redirects. A
key can have 5 endpoints.
Then send yourself a test:
curl -X POST "https://production.speakeasygo.com/external/v1/webhook-endpoints/88888888-8888-4888-8888-888888888888/test" \
-H "token: $SPEAKEASY_TOKEN"
The answer says how your server took it. It sends one signed test.ping with no customer data.
Events¶
| Event | When | relatedId |
|---|---|---|
order.confirmed |
A new order is confirmed: paid, free or comp. Once per order. | |
order.updated |
Anything in a confirmed order changed, a refund or a check-in included. | |
order.refunded |
A refund completed. Once per refund. | The refund |
ticket.checked_in |
A ticket was checked in. | The ticket |
reservation.created |
A new reservation or request. Once per reservation. | |
reservation.updated |
Anything in a reservation changed. | |
guest.updated |
A guest's details, tags or opt-out changed at a venue. | |
guest.opted_out |
A guest replied STOP. Only their own START turns texts back on. |
Ask only for what you use. If you only care about new sales, order.confirmed alone is enough, and you will not
hear about every check-in.
What we send¶
A POST with this body. data is what the route that reads the row answers at the moment
we send, with your own field groups, so it can be newer than the event.
{
"id": "99999999-9999-4999-8999-999999999999",
"type": "order.refunded",
"createdAt": "2026-11-05T10:00:04.000Z",
"venueId": "11111111-1111-4111-8111-111111111111",
"relatedId": "66666666-6666-4666-8666-666666666666",
"data": {
"id": "8c1f2d3e-0000-4000-8000-000000000001",
"status": "CONFIRMED",
"refundedAmount": 4000
}
}
The headers are Speakeasy-Signature, Speakeasy-Event-Id and Speakeasy-Event-Type. When a row is gone, or your
key can no longer reach it, data is { "id": "...", "removed": true }.
Check the signature¶
Everyone can find your address, so check each request before you trust it. The header looks like this:
Speakeasy-Signature: t=1793000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Sign t, a dot and the exact bytes of the body with HMAC SHA-256, using your secret. Compare it with each v1 in
constant time, and refuse a t more than 5 minutes old. Use the raw body: parsing and writing the JSON again
changes the bytes.
const crypto = require('crypto');
function isFromSpeakeasy(header, rawBody, secret) {
const parts = header.split(',').map((part) => part.split('='));
const time = parts.find(([key]) => key === 't')?.[1];
if (!time || Math.abs(Date.now() / 1000 - Number(time)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${time}.${rawBody}`).digest();
return parts
.filter(([key]) => key === 'v1')
.some(([, value]) => {
const given = Buffer.from(value, 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
import hashlib, hmac, time
def is_from_speakeasy(header: str, raw_body: bytes, secret: str) -> bool:
parts = [part.split("=", 1) for part in header.split(",")]
stamp = next((value for key, value in parts if key == "t"), None)
if stamp is None or abs(time.time() - int(stamp)) > 300:
return False
expected = hmac.new(secret.encode(), stamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(value, expected) for key, value in parts if key == "v1")
function isFromSpeakeasy(string $header, string $rawBody, string $secret): bool {
$time = null;
$values = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = explode('=', $part, 2);
if ($key === 't') $time = $value;
if ($key === 'v1') $values[] = $value;
}
if ($time === null || abs(time() - (int) $time) > 300) return false;
$expected = hash_hmac('sha256', $time . '.' . $rawBody, $secret);
foreach ($values as $value) {
if (hash_equals($expected, $value)) return true;
}
return false;
}
Answer quickly, and expect repeats¶
- Answer with a 2xx within 10 seconds. Anything else counts as a failure, a redirect too. Save the event and do the work afterwards.
- We can send the same event twice, for example when your answer got lost. Use
idto skip one you already have. - Events can arrive out of order, and a minute or two late. We wait about a minute after a change, so we see
everything that was saved around it. It takes longer for a key with hundreds of venues, or when a venue changes
many guests at once. Use
updatedAtindatato tell which is newer.
When your server is down¶
A failed event tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, and then once a day, for almost four days in all.
If an endpoint has failed for three days, and a test event fails too, we turn it off, and status and
disabledReason say so. Turn it on again with PATCH /external/v1/webhook-endpoints/:id and { "enabled": true }.
It starts from where it stopped reading, so the changes in between still come. After more than 25 days off, catch
up with the list routes: a change that old comes as order.updated and the like, and order.confirmed and
reservation.created only count rows from the last 25 days.
An event that gave up can be sent again:
curl -X POST "https://production.speakeasygo.com/external/v1/webhook-events/99999999-9999-4999-8999-999999999999/retry" \
-H "token: $SPEAKEASY_TOKEN"
GET /external/v1/webhook-events lists what we sent or tried to send in the last 30 days, newest first, with
status, attempts and the last error. It holds ids, not the data. Filter it with endpointId and status.
Change the secret¶
POST /external/v1/webhook-endpoints/:id/rotate-secret answers a new secret. For a day we sign with both, so the
header has two v1 values and you can change over with nothing refused. A second rotation in that day answers
409.
What a key can see¶
A webhook never tells you more than your key could read. A permission we turn off stops those events. An event
for a venue your key cannot reach right now waits for up to three days, and then it is left out. guests:contact
and the other field groups work the same as on the routes.