Skip to content

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 id to 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 updatedAt in data to 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.