Skip to content

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.

resume_from = walk("orders", {"updatedSince": resume_from}, save_order)

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 hasMany says 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.
  • total is always null on 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. Use updatedSince. 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:

  • total is what the guest paid. Do not add it up yourself. A venue can absorb the service fee, and then it is not part of total.
  • A ticket has one row per unit, so the quantity of a type is the number of rows with its ticketTypeId.
  • refundState REFUNDED means the money went back and the ticket can still enter. REFUNDED_AND_VOIDED means the ticket is cancelled too.
  • saleType COMP is a ticket staff gave away. FREE is 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, not venueStatus. status tells a request that was declined or expired from one that is still open. venueStatus is the text staff see, and it can stay REQUESTED after a decline.
  • OTHER is a status the venue made itself. See venueStatus for the text.
  • minimumBottles is the number the venue entered. On a few old rows that number is a money amount.
  • deposit is the online deposit before fees and tax. It is null when there was no online checkout.
  • Count table money once per tableBookingId. deposit, tablePaidAmount and tableRefundedAmount belong to the online table payment, and one table booking can have one reservation per seating area, so they can repeat. paylinkPaidAmount and paylinkRefundedAmount belong to this reservation alone.
  • bookedBy.name, the team member or promoter, only comes with the field group reservations:staff.
  • With the field group reservations:payments you also get refunds and disputes, each with a source of TABLE or PAYLINK. 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.