Sync orders, reservations and guests¶
This guide copies orders, table reservations and guests into your CRM or data warehouse with an
API key, through /external/v1/orders, /external/v1/reservations and /external/v1/guests.
Use these routes for a new integration. Compared with the venue routes:
- One key covers all the venues of your integration.
- Pages, with a cursor, so a big venue is no problem.
- No limit on how far back you can read, so you can load all your history yourself.
- A refund, a check-in or a paylink brings the order or reservation back on the changes list.
- Only the fields on the API reference. A new database column never reaches you by surprise.
Your key needs orders:read, reservations:read or guests:read. Email, phone and birth date also
need the field group guests:contact. Ask your Speakeasy contact.
The two steps¶
1. Load your history once. Without a filter, a list starts at the oldest row and walks to the newest, venue by venue.
import os
import time
import requests
BASE = "https://production.speakeasygo.com/external/v1"
HEADERS = {"token": os.environ["SPEAKEASY_API_KEY"]}
def walk(resource, params, save):
params = {**params, "take": 200, "includeRemoved": "true"}
while True:
answer = requests.get(f"{BASE}/{resource}", params=params, headers=HEADERS, timeout=30)
if answer.status_code in (429, 503):
time.sleep(int(answer.headers.get("Retry-After", "5")))
continue
answer.raise_for_status()
body = answer.json()
for row in body["list"]:
save(row) # insert, update by id, or delete when row["removed"]
if not body["hasMany"]:
return body["nextUpdatedSince"] # keep this for the next run
params["cursor"] = body["nextCursor"]
resume_from = walk("orders", {}, save_order)
2. Then read what changed. Send the nextUpdatedSince of your last run as updatedSince.
nextUpdatedSince is the only safe place to resume from. It comes on the last page of a list, and
it is null before that. Do not use your own clock, generatedAt or the time of the last row: a list is
not in one time order, and you would lose rows for good. If a run stops halfway, start it again from the
value you saved last. You read some rows twice, so always update by id.
A list reads changes up to about a minute ago, and the rest comes on your next run. We hold them back
because a write can take a few seconds to show, and this way nothing lands behind your resume point.
GET /external/v1/orders/{id} and the other single reads always answer the latest.
Reading a page¶
- Only
hasManysays the list has ended. A page can be short, or even empty, while more follows. This happens when a key has many venues, because a page reads one venue at a time, and when a request runs long, because it answers what it has. - Keep the same filters with the cursor. A cursor from another list answers
400 invalid_cursor. totalis alwaysnullon these lists. We do not count them, to keep them fast.- A list goes by venue, then by time, then by id. The whole list is not in one time order.
- Do not sync with
createdSince. The creation time of an order is when the checkout started, so an order paid later can be behind a time you already read. UseupdatedSince. You cannot send both. - When your key gets a new venue, load that venue's history once with
venueIds=<the venue>. The changes list only brings its rows from the time they change.
Removed rows¶
Send includeRemoved=true and a list also answers rows that were removed, so you can delete your copy:
{ "id": "8c1f2d3e-0000-4000-8000-000000000001", "removed": true, "updatedAt": "2026-11-02T10:05:00.000Z" }
A removed row has no other data. A removed guest also has venueId, because a guest's row in a list is
about one venue and the guest can stay at another. A guest who deleted their account is a removed row at
every venue. Reading a removed row by its id answers 404.
How long a load takes¶
At the usual limit of 60 requests a minute and take=200, you read 12,000 rows a minute. Our biggest
venue would take about 21 minutes for its orders and 19 minutes for its guests. If that is too slow,
ask your Speakeasy contact.
Orders¶
GET /external/v1/orders answers CONFIRMED orders: paid, free or comp. Ask for status=PENDING or
status=FAILED to see checkouts that have not finished or that ended with no sale. A PENDING order
that is more than a day old is a checkout the guest left, not a sale. Paycode orders are not on this
route.
{
"id": "8c1f2d3e-0000-4000-8000-000000000001",
"venueId": "11111111-1111-4111-8111-111111111111",
"type": "EVENT",
"status": "CONFIRMED",
"saleType": "PAID",
"source": "DIRECT",
"event": {
"id": "22222222-2222-4222-8222-222222222222",
"title": "New Year at Sample Bar",
"startDateTime": "2026-12-31T04:00:00.000Z"
},
"guestId": "gst_a3fe9c709951b1d3052be583b77326f3",
"currency": "USD",
"ticketCount": 2,
"checkedInCount": 1,
"refundedCount": 1,
"subtotal": 8000,
"discount": 500,
"serviceFee": 1234,
"customFees": [{ "name": "Venue fee", "amount": 300 }],
"taxes": [{ "name": "Sales Tax", "amount": 658 }],
"total": 9692,
"refundedAmount": 4000,
"tickets": [
{
"id": "44444444-4444-4444-8444-444444444444",
"ticketTypeId": "55555555-5555-4555-8555-555555555555",
"name": "General Admission",
"unitAmount": 4000,
"checkedIn": true,
"checkedInAt": "2026-12-31T05:12:44.000Z",
"refundState": "NONE"
},
{
"id": "44444444-4444-4444-8444-444444444445",
"ticketTypeId": "55555555-5555-4555-8555-555555555555",
"name": "General Admission",
"unitAmount": 4000,
"checkedIn": false,
"checkedInAt": null,
"refundState": "REFUNDED"
}
],
"createdAt": "2026-11-01T10:00:00.000Z",
"updatedAt": "2026-11-02T10:05:00.000Z"
}
Good to know:
totalis what the guest paid. Do not add it up yourself. A venue can absorb the service fee, and then it is not part oftotal.- A ticket has one row per unit, so the quantity of a type is the number of rows with its
ticketTypeId. refundStateREFUNDEDmeans the money went back and the ticket can still enter.REFUNDED_AND_VOIDEDmeans the ticket is cancelled too.saleTypeCOMPis a ticket staff gave away.FREEis an order with a total of 0.
Reservations¶
GET /external/v1/reservations answers tables and requests. Deleted rows are left out.
{
"id": "66666666-6666-4666-8666-666666666666",
"venueId": "11111111-1111-4111-8111-111111111111",
"status": "DECLINED",
"venueStatus": "REQUESTED",
"isRequest": true,
"event": null,
"date": "2026-12-31T00:00:00.000Z",
"time": "2026-12-31T05:00:00.000Z",
"seatingArea": { "id": "77777777-7777-4777-8777-777777777777", "name": "Main floor" },
"partySize": 6,
"minimumType": "AMOUNT",
"minimumSpend": 150000,
"minimumBottles": null,
"currency": "USD",
"tableBookingId": null,
"deposit": null,
"tablePaidAmount": 0,
"tableRefundedAmount": 0,
"paylinkPaidAmount": 0,
"paylinkRefundedAmount": 0,
"bookedBy": { "type": "REQUESTED" },
"guestId": "gst_a3fe9c709951b1d3052be583b77326f3",
"createdAt": "2026-11-01T10:00:00.000Z",
"updatedAt": "2026-11-02T10:05:00.000Z"
}
Good to know:
- Use
status, notvenueStatus.statustells a request that was declined or expired from one that is still open.venueStatusis the text staff see, and it can stayREQUESTEDafter a decline. OTHERis a status the venue made itself. SeevenueStatusfor the text.minimumBottlesis the number the venue entered. On a few old rows that number is a money amount.depositis the online deposit before fees and tax. It isnullwhen there was no online checkout.- Count table money once per
tableBookingId.deposit,tablePaidAmountandtableRefundedAmountbelong to the online table payment, and one table booking can have one reservation per seating area, so they can repeat.paylinkPaidAmountandpaylinkRefundedAmountbelong to this reservation alone. bookedBy.name, the team member or promoter, only comes with the field groupreservations:staff.- With the field group
reservations:paymentsyou also getrefundsanddisputes, each with asourceofTABLEorPAYLINK. See Refunds and disputes.
Guests¶
GET /external/v1/guests answers one row per guest and venue, so a guest of two of your venues comes
twice, with the same id. GET /external/v1/guests/{id} answers the guest with every venue your key
can read. The id is the guestId of an order or a reservation.
The guest id is yours. It is gst_ and 32 hex characters. It is stable: the same guest keeps the same
id across all your venues, so you can store it and match on it. Another company sees a different id for
the same guest, so nobody can match their list with yours. An id only works with your key: anything else
answers 404, like an id that does not exist. It is not the id the older routes under /partners answer.
{
"id": "gst_a3fe9c709951b1d3052be583b77326f3",
"firstName": "Sam",
"lastName": "Guest",
"email": "sam@example.com",
"phone": "+15555550100",
"birthDate": "1990-05-01",
"venues": [
{
"venueId": "11111111-1111-4111-8111-111111111111",
"currency": "USD",
"totalSpent": 123456,
"purchases": 3,
"lastVisitedAt": "2026-10-01T00:00:00.000Z",
"cancels": 0,
"noShows": 1,
"isVip": true,
"isMember": false,
"memberSince": null,
"sms": { "optedOut": true, "stoppedAt": null },
"emailOptedIn": false,
"tags": ["Regular"],
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-03-01T00:00:00.000Z"
}
],
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-03-01T00:00:00.000Z"
}
Respect opt-outs. When sms.optedOut is true, texts are off for this guest at this venue. Do not
text them from your own tools. It is true for every state but on, so a guest we are not sure about
counts as opted out. An opt-out brings the guest back on the changes list. sms.stoppedAt is when the
guest replied STOP, if we have that time. Empty does not mean texts are on, so read optedOut.
Email, phone and birth date only come with the field group guests:contact, and tags, which staff
type as free text, with guests:tags. Without them, those fields are not in the answer at all.
Refunds and disputes¶
With the field group orders:payments an order also answers each refund and dispute. With
reservations:payments a reservation does the same. Without the field group you only get the refunded
amounts.
{
"refunds": [
{
"id": "66666666-6666-4666-8666-666666666666",
"amount": 4000,
"status": "SUCCEEDED",
"createdAt": "2026-11-03T10:00:00.000Z",
"updatedAt": "2026-11-03T10:00:04.000Z"
}
],
"disputes": [
{
"id": "77777777-7777-4777-8777-777777777777",
"amount": 9692,
"status": "NEEDS_RESPONSE",
"createdAt": "2026-11-04T10:00:00.000Z",
"updatedAt": "2026-11-04T10:00:00.000Z"
}
]
}
A refund or a dispute moves its order or reservation, so it comes back through updatedSince. Only a refund
with SUCCEEDED counts in the refunded amounts. Refund status is REQUESTED, IN_PROGRESS, SUCCEEDED or
FAILED. Dispute status is NEEDS_RESPONSE, UNDER_REVIEW, WON, LOST, CHARGE_REFUNDED, or a WARNING_
value for an early warning from the card network.
A table booking's refunds and disputes repeat on each of its reservations, like its money. Count them once
per id.
What does not bring a row back¶
The changes list follows the row and what we mark for it: for an order its tickets, check-ins, refunds and disputes, for a reservation its paylinks, table payment and request, for a guest their name, email, phone, tags and opt-out. What we read only when we answer does not bring a row back: the title of an event, the name of a ticket type or a seating area, and the venue's currency. Read a row again when you need the latest of those.
currency is the venue's setting when we answer. It is not stored with each order, so if a venue
changes its currency, its old amounts come with the new code.