This article has been archived as it contains outdated information, non-working examples, or because I am no longer happy with the content. It has been made unlisted, and can still be accessed with a direct link.

Overview

Travelling frequently to the United States means you've heard of Global Entry. It's a program that allows you to skip the customs line when you're entering the United States. Instead of waiting through long lines, you can walk up to a kiosk, scan your passport, answer a few questions, and be on your way. However, travelling with someone who doesn't have it can be disappointing as you have to go through the regular customs line with them. After doing this once with my partner, I signed her up for Global Entry to skip the line for our next trip abroad.

Part of the signup process is an interview with a CBP agent, which is relatively easy to book. I got an appointment six months in advance, which they cancelled without warning a couple of days before the appointment was scheduled to take place. When I looked to reschedule, I found that it couldn't even be at the airport near us; you couldn't even join a waitlist. Getting a replacement appointment proved challenging until I decided to get creative.

Window from a plane in flight
I want a smooooth trip

Researching

I started digging deeper into how the Global Entry appointment scheduler application works. I noticed that you can cancel or reschedule your own appointment. Once this happens, the selection is released back into the pool for others to take.

The second thing I noticed by running a network trace in Google Chrome was that the page is making an unauthenticated request to an API endpoint which returns a list of available appointments based on a location id. This is convenient as I can request this without even being logged in.

$terminal
$ GET https://ttp.cbp.dhs.gov/schedulerapi/slots?orderBy=soonest&limit=1&locationId=7540&minimum=1

[
  {
    "locationId" : 7540,
    "startTimestamp" : "2023-06-07T08:45",
    "endTimestamp" : "2023-06-07T09:00",
    "active" : true,
    "duration" : 15,
    "remoteInd" : false
  }
]

Using this information, I plotted to write a script to poll this endpoint every 5 minutes and alert me if an appointment became available. As there are three airports in the area that I wanted to check against, I needed this script to be robust enough to ping all three simultaneously.

Building the Prototype

With Node, I wrote a function that makes an API request to the CBP API and then formats the data so it's easier to digest by converting the timestamps using toLocaleString().

appointments.js
const getAvailableAppointments = async ({ locationId, limit }) => {
  const response = await fetch(
    `https://ttp.cbp.dhs.gov/schedulerapi/slots?orderBy=soonest&limit=${limit}&locationId=${locationId}&minimum=1`,
  )
  const data = await response.json()

  return data.map((appointment) => ({
    // Gotta make sure our timezones are correct!
    start: new Date(appointment.startTimestamp).toLocaleString('en-GB', {
      timeZone: 'America/New_York',
    }),
    end: new Date(appointment.endTimestamp).toLocaleString('en-GB', {
      timeZone: 'America/New_York',
    }),
  }))
}

(async () => {
  const appointments = await getAvailableAppointments({
    locationId: 7540,
    limit: 1,
  })

  console.log(appointments) // [ { start: '6/10/2023, 9:45:00 AM', end: '6/10/2023, 10:00:00 AM' } ]
})()

Using a network trace, I located an endpoint that returns a list of all available airports that the scheduler maintains. I found the ones I needed by doing a quick find on the names and creating an enum object to interchangeably reference the id and human-readable terms within my codebase.

constants.js
const LocationId = {
  JFK: 7540,
  LGA: 7554,
  EWR: 7551,
}

const getAirportCode = (locationId) =>
  Object.keys(LocationId).find((key) => LocationId[key] === locationId)

console.log(getAirportCode(7540)) // JFK

Supporting Multiple Airports

At this point, the script only supports a single airport lookup, but there are three in the area I want to check, so I need to scale it.

The refactor for this is straightforward. The input needs to instead accept an array of ids, and a request needs to be made for each provided airport id. Using a Promise.map, we can resolve each request before moving the combined returned data to the next step.

appointments.js
const getAvailableAppointments = async ({ locationIds, limit }) => {
  const allAppointments = []

  await Promise.all(
    locationIds.map(async (locationId) => {
      const response = await fetch(
        `https://ttp.cbp.dhs.gov/schedulerapi/slots?orderBy=soonest&limit=${limit}&locationId=${locationId}&minimum=1`,
      )

      const data = await response.json()

      const availableAppointments = data.map((appointment) => ({
        location: getAirportCode(appointment.locationId),
        start: new Date(appointment.startTimestamp).toLocaleString('en-GB', {
          timeZone: 'America/New_York',
        }),
        end: new Date(appointment.endTimestamp).toLocaleString('en-GB', {
          timeZone: 'America/New_York',
        }),
      }))

      allAppointments.push(...availableAppointments)
    }),
  )

  return allAppointments
}

(async () => {
  const appointments = await getAvailableAppointments({
    // locationId is now locationIds and accepts an array of location ids
    locationIds: [LocationId.JFK, LocationId.EWR, LocationId.LGA],
    limit: 1,
  })

  console.log(appointments) // [ { location: 'JFK', start: '6/10/2023, 9:45:00 AM', end: '6/10/2023, 10:00:00 AM' }, { location: 'LGA', start: '6/10/2023, 9:45:00 AM', end: '6/10/2023, 10:00:00 AM' } ]
})()

Sending the Alert

As appointments can become available at any time throughout the day, I would like a text message to be used as the delivery method for the notification. I decided to use the Twilio API for this; the only downfall is that you get limited credits with a free trial. This should be fine, though, as we're only sending a handful of messages overall.

The following shows what I came up with:

appointments.js
const sendTextMessage = async (appointment) => {
  const accountSid = process.env.TWILIO_ACCOUNT_SID
  const authToken = process.env.TWILIO_AUTH_TOKEN
  const client = require('twilio')(accountSid, authToken)

  await client.messages.create({
    body: `There is a TTP appointment available at ${appointment.location} on ${appointment.start}! ✈️ 📅`,
    from: process.env.TWILIO_PHONE_NUMBER,
    to: process.env.MY_PHONE_NUMBER,
  })
}

(async () => {
  const appointments = await getAvailableAppointments({
    locationIds: [LocationId.JFK, LocationId.EWR, LocationId.LGA],
    limit: 1,
  })

  // Only call the sendTextMessage function if there are appointments available, otherwise log that there are none
  if (appointments.length) {
    appointments.map((appointment) => sendTextMessage(appointment))
  } else {
    console.log('No appointments available')
  }
})()

As you can see from the picture below, this works well.

Apple watch showing a text message with the appointment details
Originally I wanted to include a quick link here but found that Twilio would block my messages as spam with it. You can get around this by paying Twilio money apparently.

Scheduling the Script

All left to do now is schedule the script so it runs on a cadence. This can be done using JavaScript's setInterval.

appointments.js
const init = async () => {
  console.log('Checking for appointments...')
  const appointments = await getAvailableAppointments({
    locationIds: [LocationId.JFK, LocationId.EWR],
    limit: 1,
  })

  if (appointments.length) {
    appointments.map((appointment) => sendTextMessage(appointment))
  }
}

// Run the script every 5 minutes, I worried there may be rate limits so I didn't wnat to hit it too often.
console.log('Starting appointment checker...')
setInterval(init, 100000)

With the script completed, I just needed to start it and wait for the text message to book an appointment. I had this running on my laptop, but you could if you wanted to deploy it to a service with a serverless function. However, it might be slightly overkill for this use case considering how straightforward this is.

Conclusion

So does it work? Absolutely! I ran this for a few hours before a text message was sent to my phone to book an appointment. It would be best to be quick once you get the notification, but overall this work is a resounding success.

If you want the script to try it out, you can find it here on GitHub. I wouldn't be surprised if other endpoints are public, too, for things such as Visa and Greencard appointments and could be leveraged similarly. I'll leave that up to you to find out, though. Best of luck!