├── .github └── funding.yml ├── .gitignore ├── globalentry.png ├── go.mod ├── .goreleaser.yaml ├── Makefile ├── LICENSE ├── .circleci └── config.yml ├── go.sum ├── cmd ├── main.go └── main_test.go └── README.md /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: arun0009 2 | buy_me_a_coffee: arun0009 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | bin/ 4 | vendor/ 5 | global-entry-slot-notifier -------------------------------------------------------------------------------- /globalentry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arun0009/global-entry-slot-notifier/HEAD/globalentry.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/arun0009/global-entry-slot-notifier 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 7 | github.com/spf13/cobra v1.9.1 8 | ) 9 | 10 | require ( 11 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect 12 | github.com/godbus/dbus/v5 v5.1.0 // indirect 13 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 14 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect 15 | github.com/spf13/pflag v1.0.6 // indirect 16 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 17 | golang.org/x/sys v0.6.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | builds: 4 | - id: global-entry-slot-notifier 5 | main: ./cmd/main.go 6 | binary: global-entry-slot-notifier 7 | env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - linux 11 | - darwin 12 | - windows 13 | goarch: 14 | - amd64 15 | - arm64 16 | ldflags: 17 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} 18 | flags: 19 | - -trimpath 20 | 21 | archives: 22 | - id: default 23 | builds: 24 | - global-entry-slot-notifier 25 | format: tar.gz 26 | name_template: "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 27 | files: 28 | - LICENSE 29 | - README.md 30 | 31 | release: 32 | github: 33 | owner: arun0009 34 | name: global-entry-slot-notifier 35 | draft: false 36 | prerelease: auto 37 | name_template: "{{ .Tag }}" 38 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Define variables 2 | BINARY_NAME=global-entry-slot-notifier 3 | SRC_DIR=./cmd/main.go 4 | WITH_FLAGS= 5 | 6 | # Default target 7 | .PHONY: all 8 | all: deps build 9 | 10 | # Build the binary 11 | .PHONY: build 12 | build: 13 | @echo "Building $(BINARY_NAME)..." 14 | @go build -o $(BINARY_NAME) $(SRC_DIR) 15 | 16 | # Run tests 17 | .PHONY: test 18 | test: 19 | @echo "Running tests..." 20 | @go test -v ./... 21 | 22 | # Clean build files 23 | .PHONY: clean 24 | clean: 25 | @echo "Cleaning..." 26 | @rm -f $(BINARY_NAME) 27 | 28 | 29 | # Install dependencies 30 | .PHONY: deps 31 | deps: 32 | @echo "Installing dependencies..." 33 | @go mod tidy 34 | 35 | # Display help 36 | .PHONY: help 37 | help: 38 | @echo "Makefile targets:" 39 | @echo " build - Build the binary" 40 | @echo " test - Run tests" 41 | @echo " clean - Clean build files" 42 | @echo " deps - Install dependencies" 43 | @echo " help - Display this help message" 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Arun Gopalpuri 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation 4 | files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, 5 | modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the 6 | Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 11 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 12 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 13 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2.1 3 | 4 | orbs: 5 | goreleaser: hubci/goreleaser@2.5.0 6 | 7 | executors: 8 | golang: 9 | docker: 10 | - image: cimg/go:1.23 11 | 12 | jobs: 13 | build: 14 | executor: golang 15 | steps: 16 | - checkout 17 | - run: go mod download 18 | - run: go build -o global-entry-slot-notifier cmd/main.go 19 | 20 | workflows: 21 | version: 2 22 | global-entry-slot-notifier: 23 | jobs: 24 | - build: 25 | filters: 26 | tags: 27 | only: /.*/ 28 | - goreleaser/release: 29 | name: test-release 30 | version: '2.1.0' 31 | go-version: '1.20' 32 | dry-run: true 33 | requires: 34 | - build 35 | filters: 36 | tags: 37 | only: /.*/ 38 | - goreleaser/release: 39 | name: release 40 | version: '2.1.0' 41 | go-version: '1.20' 42 | requires: 43 | - build 44 | filters: 45 | tags: 46 | only: /^v.*/ 47 | branches: 48 | ignore: /.*/ -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI= 3 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4= 4 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= 5 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= 6 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 7 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 8 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 9 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 10 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 11 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 12 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 13 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 14 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 15 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 16 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 17 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= 18 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= 19 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 20 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 22 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 23 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/gen2brain/beeep" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | const hhmmLayout = "15:04" // Go’s layout for HH:MM (24-hour) 19 | 20 | // Set to 10 to search through multiple time slots. 21 | const GlobalEntryUrl = "https://ttp.cbp.dhs.gov/schedulerapi/slots?orderBy=soonest&limit=10&locationId=%s&minimum=1" 22 | 23 | // Appointment represents the structure of the appointment JSON returned by the Global Entry API response. 24 | type Appointment struct { 25 | LocationID int `json:"locationId"` 26 | StartTimestamp string `json:"startTimestamp"` 27 | EndTimestamp string `json:"endTimestamp"` 28 | Active bool `json:"active"` 29 | Duration int `json:"duration"` 30 | RemoteInt bool `json:"remoteInd"` 31 | } 32 | 33 | // HTTPClient is an interface for making HTTP requests. 34 | type HTTPClient interface { 35 | Get(url string) (resp *http.Response, err error) 36 | Post(url, contentType string, body io.Reader) (resp *http.Response, err error) 37 | } 38 | 39 | // Notifier is an interface for sending notifications. 40 | type Notifier interface { 41 | Notify(locationID int, startTime string, topic string) error 42 | } 43 | 44 | // CheckOptions holds optional time-of-day filters in minutes after midnight; -1 means unset. 45 | type CheckOptions struct { 46 | EarliestMinutes int 47 | LatestMinutes int 48 | } 49 | 50 | // AppNotifier sends notifications via an app. 51 | type AppNotifier struct { 52 | Client HTTPClient 53 | } 54 | 55 | func (a AppNotifier) Notify(locationID int, startTime string, topic string) error { 56 | _, err := a.Client.Post(fmt.Sprintf("https://ntfy.sh/%s", topic), "text/plain", 57 | strings.NewReader(fmt.Sprintf("Global Entry appointment available at %d on %s", locationID, startTime))) 58 | return err 59 | } 60 | 61 | // SystemNotifier sends system notifications. 62 | type SystemNotifier struct{} 63 | 64 | func (s SystemNotifier) Notify(locationID int, startTime string, topic string) error { 65 | return beeep.Notify("Appointment Slot Available", fmt.Sprintf("Appointment at %d on %s", locationID, startTime), "assets/information.png") 66 | } 67 | 68 | // parseHHMM parses "HH:MM" in 24-hour format and returns minutes since midnight. 69 | // If input is invalid, returns -1. 70 | func parseHHMM(s string) int { 71 | if s == "" { 72 | return -1 73 | } 74 | t, err := time.Parse(hhmmLayout, s) 75 | if err != nil { 76 | return -1 77 | } 78 | return t.Hour()*60 + t.Minute() 79 | } 80 | 81 | // appointmentCheckScheduler calls the provided appointmentCheck function at regular intervals. 82 | func appointmentCheckScheduler(interval time.Duration, appointmentCheck func()) { 83 | ticker := time.NewTicker(interval) 84 | defer ticker.Stop() 85 | for { 86 | select { 87 | case <-ticker.C: 88 | appointmentCheck() 89 | } 90 | } 91 | } 92 | 93 | // appointmentCheck retrieves the appointment slots and triggers the appropriate notifier. 94 | func appointmentCheck(url string, client HTTPClient, notifier Notifier, topic string, beforeDate time.Time, opts CheckOptions) { 95 | response, err := client.Get(url) 96 | if err != nil { 97 | log.Printf("Failed to get appointment slots: %v", err) 98 | return 99 | } 100 | defer response.Body.Close() 101 | 102 | responseData, err := io.ReadAll(response.Body) 103 | if err != nil { 104 | log.Printf("Failed to read response body: %v", err) 105 | return 106 | } 107 | 108 | var appointments []Appointment 109 | err = json.Unmarshal(responseData, &appointments) 110 | if err != nil { 111 | log.Printf("Failed to unmarshal response data: %v", err) 112 | return 113 | } 114 | 115 | found := false 116 | 117 | for _, appointment := range appointments { 118 | appointmentTime, err := time.Parse("2006-01-02T15:04", appointment.StartTimestamp) 119 | if err != nil { 120 | log.Printf("Failed to parse appointment time: %v", err) 121 | continue 122 | } 123 | 124 | // Filter out appointments after cutoff date 125 | if appointmentTime.After(beforeDate) { 126 | continue 127 | } 128 | 129 | // Optional time-of-day window 130 | if opts.EarliestMinutes >= 0 || opts.LatestMinutes >= 0 { 131 | minOfDay := appointmentTime.Hour()*60 + appointmentTime.Minute() 132 | if opts.EarliestMinutes >= 0 && minOfDay < opts.EarliestMinutes { 133 | continue 134 | } 135 | if opts.LatestMinutes >= 0 && minOfDay > opts.LatestMinutes { 136 | continue 137 | } 138 | } 139 | 140 | // Valid appointment 141 | if err := notifier.Notify(appointment.LocationID, appointment.StartTimestamp, topic); err != nil { 142 | log.Printf("Failed to send notification for %s: %v", appointment.StartTimestamp, err) 143 | continue 144 | } 145 | found = true 146 | break // notify once per cycle (first valid, API is orderBy=soonest) 147 | } 148 | 149 | if !found { 150 | log.Printf("[%s] No valid appointments found", time.Now().Format("2006-01-02 15:04:05")) 151 | } 152 | } 153 | 154 | func main() { 155 | var location, notifierType, topic, before string 156 | var earliestStr, latestStr string 157 | var interval time.Duration 158 | 159 | rootCmd := &cobra.Command{ 160 | Use: "global-entry-slot-notifier", 161 | Short: "Checks for appointment slots and sends notifications", 162 | Run: func(cmd *cobra.Command, args []string) { 163 | // Function to prompt user for input 164 | getInput := func(prompt string) string { 165 | reader := bufio.NewReader(os.Stdin) 166 | fmt.Print(prompt) 167 | input, _ := reader.ReadString('\n') 168 | return strings.TrimSpace(input) 169 | } 170 | 171 | // Check and prompt for missing flags 172 | if location == "" { 173 | location = getInput("Enter the location ID: ") 174 | } 175 | if notifierType == "" { 176 | notifierType = getInput("Enter the notifier type (app/system): ") 177 | } 178 | if notifierType == "app" && topic == "" { 179 | topic = getInput("Enter the ntfy topic: ") 180 | } 181 | 182 | // Validate flags 183 | if location == "" || notifierType == "" || (notifierType == "app" && topic == "") { 184 | fmt.Println("Both --location and --notifier flags are required. If notifier is app, --topic is required.") 185 | _ = cmd.Usage() 186 | os.Exit(1) 187 | } 188 | 189 | url := fmt.Sprintf(GlobalEntryUrl, location) 190 | 191 | var notifier Notifier 192 | client := &http.Client{} 193 | switch notifierType { 194 | case "app": 195 | notifier = AppNotifier{Client: client} 196 | case "system": 197 | notifier = SystemNotifier{} 198 | default: 199 | log.Fatalf("Unknown notifier type: %s", notifierType) 200 | } 201 | 202 | beforeDate := time.Now().AddDate(1, 0, 0) // Default: 1 year from now 203 | if before != "" { 204 | parsedBefore, err := time.Parse("2006-01-02", before) 205 | if err == nil { 206 | beforeDate = parsedBefore 207 | } else { 208 | log.Printf("Invalid before date format, using default (1 year from now)") 209 | } 210 | } 211 | 212 | // Build options from optional earliest/latest (HH:MM, 24-hour). If invalid, ignore. 213 | earliestMin := parseHHMM(earliestStr) 214 | latestMin := parseHHMM(latestStr) 215 | 216 | if earliestStr != "" && earliestMin < 0 { 217 | log.Printf("Invalid --earliest (expected HH:MM), ignoring") 218 | earliestMin = -1 219 | } 220 | if latestStr != "" && latestMin < 0 { 221 | log.Printf("Invalid --latest (expected HH:MM), ignoring") 222 | latestMin = -1 223 | } 224 | if earliestMin >= 0 && latestMin >= 0 && latestMin < earliestMin { 225 | log.Printf("--latest is before --earliest; ignoring time window") 226 | earliestMin, latestMin = -1, -1 227 | } 228 | 229 | opts := CheckOptions{ 230 | EarliestMinutes: earliestMin, 231 | LatestMinutes: latestMin, 232 | } 233 | 234 | // Create a closure that captures the arguments and calls appointmentCheck with them. 235 | appointmentCheckFunc := func() { 236 | appointmentCheck(url, client, notifier, topic, beforeDate, opts) 237 | } 238 | 239 | go appointmentCheckScheduler(interval, appointmentCheckFunc) 240 | 241 | // Keep the main function running to allow the ticker to tick. 242 | select {} 243 | }, 244 | } 245 | 246 | rootCmd.Flags().StringVarP(&location, "location", "l", "", "Specify the location ID") 247 | rootCmd.Flags().StringVarP(¬ifierType, "notifier", "n", "", "Specify the notifier type (app or system)") 248 | rootCmd.Flags().StringVarP(&topic, "topic", "t", "", "Specify the ntfy topic (required if notifier is app)") 249 | rootCmd.Flags().DurationVarP(&interval, "interval", "i", time.Second*60, "Specify the interval") 250 | rootCmd.Flags().StringVarP(&before, "before", "b", "", "Show only appointments before the specified date (YYYY-MM-DD)") 251 | rootCmd.Flags().StringVarP(&earliestStr, "earliest", "e", "", "Only appointments at/after this time (HH:MM, 24-hour)") 252 | rootCmd.Flags().StringVarP(&latestStr, "latest", "L", "", "Only appointments at/before this time (HH:MM, 24-hour)") 253 | 254 | if err := rootCmd.Execute(); err != nil { 255 | fmt.Println(err) 256 | os.Exit(1) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /cmd/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | // MockHTTPClient is a mock implementation of the HTTPClient interface for testing. 14 | type MockHTTPClient struct { 15 | Response *http.Response 16 | Err error 17 | } 18 | 19 | func (m *MockHTTPClient) Get(url string) (*http.Response, error) { 20 | return m.Response, m.Err 21 | } 22 | 23 | func (m *MockHTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) { 24 | return m.Response, m.Err 25 | } 26 | 27 | // MockNotifier is a mock implementation of the Notifier interface for testing. 28 | type MockNotifier struct { 29 | Called bool 30 | LocationID int 31 | StartTime string 32 | Topic string 33 | ReturnError bool 34 | } 35 | 36 | func (m *MockNotifier) Notify(locationID int, startTime string, topic string) error { 37 | m.Called = true 38 | m.LocationID = locationID 39 | m.StartTime = startTime 40 | m.Topic = topic 41 | if m.ReturnError { 42 | return errors.New("mock error") 43 | } 44 | return nil 45 | } 46 | 47 | // ---- Helpers for constructing timestamps used by tests ---- 48 | 49 | const apiTimeLayout = "2006-01-02T15:04" 50 | 51 | // makeTS returns an API-formatted timestamp at (now + dayOffset) with H:M. 52 | func makeTS(dayOffset int, hour, min int) string { 53 | base := time.Now().AddDate(0, 0, dayOffset) 54 | t := time.Date(base.Year(), base.Month(), base.Day(), hour, min, 0, 0, base.Location()) 55 | return t.Format(apiTimeLayout) 56 | } 57 | 58 | // makeJSON is a small helper to marshal appointments. 59 | func makeJSON(appts []Appointment) string { 60 | b, _ := json.Marshal(appts) 61 | return string(b) 62 | } 63 | 64 | // defaultOpts returns CheckOptions with no time-of-day filtering. 65 | func defaultOpts() CheckOptions { 66 | return CheckOptions{EarliestMinutes: -1, LatestMinutes: -1} 67 | } 68 | 69 | // ----------------------------------------------------------- 70 | 71 | func TestAppointmentCheck(t *testing.T) { 72 | futureTime := makeTS(1, 10, 30) // 24h+ from now, API layout "2006-01-02T15:04" 73 | appointments := []Appointment{ 74 | {LocationID: 123, StartTimestamp: futureTime}, 75 | } 76 | appointmentsJSON := makeJSON(appointments) 77 | 78 | mockClient := &MockHTTPClient{ 79 | Response: &http.Response{ 80 | StatusCode: http.StatusOK, 81 | Body: io.NopCloser(strings.NewReader(appointmentsJSON)), 82 | }, 83 | Err: nil, 84 | } 85 | mockNotifier := &MockNotifier{} 86 | 87 | url := "http://example.com" 88 | topic := "test-topic" 89 | beforeDate := time.Now().Add(48 * time.Hour) // Allow appointments within 48 hours 90 | 91 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, defaultOpts()) 92 | 93 | if !mockNotifier.Called { 94 | t.Fatalf("Expected Notify to be called") 95 | } 96 | if mockNotifier.LocationID != 123 { 97 | t.Errorf("Expected LocationID to be %d, got %d", 123, mockNotifier.LocationID) 98 | } 99 | if mockNotifier.Topic != topic { 100 | t.Errorf("Expected topic to be %s, got %s", topic, mockNotifier.Topic) 101 | } 102 | } 103 | 104 | func TestAppointmentCheck_NoAppointments(t *testing.T) { 105 | appointmentsJSON := makeJSON([]Appointment{}) 106 | 107 | mockClient := &MockHTTPClient{ 108 | Response: &http.Response{ 109 | StatusCode: http.StatusOK, 110 | Body: io.NopCloser(strings.NewReader(appointmentsJSON)), 111 | }, 112 | Err: nil, 113 | } 114 | mockNotifier := &MockNotifier{} 115 | 116 | url := "http://example.com" 117 | topic := "test-topic" 118 | beforeDate := time.Now().Add(48 * time.Hour) // Allow appointments within 48 hours 119 | 120 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, defaultOpts()) 121 | 122 | if mockNotifier.Called { 123 | t.Fatalf("Expected Notify not to be called") 124 | } 125 | } 126 | 127 | func TestAppointmentCheck_AppointmentOutsideBeforeDate(t *testing.T) { 128 | // 3 days from now (outside 48h window), using the API layout so it parses. 129 | outsideTime := makeTS(3, 9, 0) 130 | appointments := []Appointment{ 131 | {LocationID: 123, StartTimestamp: outsideTime}, 132 | } 133 | appointmentsJSON := makeJSON(appointments) 134 | 135 | mockClient := &MockHTTPClient{ 136 | Response: &http.Response{ 137 | StatusCode: http.StatusOK, 138 | Body: io.NopCloser(strings.NewReader(appointmentsJSON)), 139 | }, 140 | Err: nil, 141 | } 142 | mockNotifier := &MockNotifier{} 143 | 144 | url := "http://example.com" 145 | topic := "test-topic" 146 | beforeDate := time.Now().Add(48 * time.Hour) // Only allow appointments within 2 days 147 | 148 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, defaultOpts()) 149 | 150 | if mockNotifier.Called { 151 | t.Fatalf("Expected Notify not to be called for appointments after beforeDate") 152 | } 153 | } 154 | 155 | func TestAppointmentCheck_HTTPError(t *testing.T) { 156 | mockClient := &MockHTTPClient{ 157 | Response: nil, 158 | Err: errors.New("http error"), 159 | } 160 | mockNotifier := &MockNotifier{} 161 | 162 | url := "http://example.com" 163 | topic := "test-topic" 164 | beforeDate := time.Now().Add(48 * time.Hour) // Allow appointments within 48 hours 165 | 166 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, defaultOpts()) 167 | 168 | if mockNotifier.Called { 169 | t.Fatalf("Expected Notify not to be called") 170 | } 171 | } 172 | 173 | func TestAppointmentCheck_UnmarshalError(t *testing.T) { 174 | mockClient := &MockHTTPClient{ 175 | Response: &http.Response{ 176 | StatusCode: http.StatusOK, 177 | Body: io.NopCloser(strings.NewReader("invalid json")), 178 | }, 179 | Err: nil, 180 | } 181 | mockNotifier := &MockNotifier{} 182 | 183 | url := "http://example.com" 184 | topic := "test-topic" 185 | beforeDate := time.Now().Add(48 * time.Hour) // Allow appointments within 48 hours 186 | 187 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, defaultOpts()) 188 | 189 | if mockNotifier.Called { 190 | t.Fatalf("Expected Notify not to be called") 191 | } 192 | } 193 | 194 | func TestAppointmentCheckScheduler(t *testing.T) { 195 | count := 0 196 | appointmentCheckFn := func() { 197 | count++ 198 | } 199 | 200 | go appointmentCheckScheduler(1*time.Second, appointmentCheckFn) 201 | 202 | time.Sleep(3500 * time.Millisecond) 203 | 204 | if count != 3 { 205 | t.Errorf("Expected appointmentCheck to be called 3 times, got %d", count) 206 | } 207 | } 208 | 209 | // ---------------- New tests for earliest/latest filtering ---------------- 210 | 211 | func TestAppointmentCheck_EarliestOnly(t *testing.T) { 212 | // earliest = 08:00; appointments at 07:00 (filtered) and 09:00 (valid) 213 | appts := []Appointment{ 214 | {LocationID: 1, StartTimestamp: makeTS(1, 7, 0)}, 215 | {LocationID: 2, StartTimestamp: makeTS(1, 9, 0)}, 216 | } 217 | mockClient := &MockHTTPClient{ 218 | Response: &http.Response{ 219 | StatusCode: http.StatusOK, 220 | Body: io.NopCloser(strings.NewReader(makeJSON(appts))), 221 | }, 222 | } 223 | mockNotifier := &MockNotifier{} 224 | url := "http://example.com" 225 | topic := "topic" 226 | beforeDate := time.Now().Add(72 * time.Hour) 227 | 228 | opts := CheckOptions{ 229 | EarliestMinutes: 8 * 60, 230 | LatestMinutes: -1, 231 | } 232 | 233 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, opts) 234 | 235 | if !mockNotifier.Called { 236 | t.Fatalf("Expected Notify to be called for appointment >= earliest") 237 | } 238 | // Should notify on the first valid (09:00), not the 07:00 one. 239 | if mockNotifier.LocationID != 2 { 240 | t.Errorf("Expected LocationID 2 (09:00), got %d", mockNotifier.LocationID) 241 | } 242 | } 243 | 244 | func TestAppointmentCheck_LatestOnly(t *testing.T) { 245 | // latest = 10:00; appointments at 09:30 (valid) then 11:00 (filtered) 246 | appts := []Appointment{ 247 | {LocationID: 10, StartTimestamp: makeTS(1, 9, 30)}, 248 | {LocationID: 11, StartTimestamp: makeTS(1, 11, 0)}, 249 | } 250 | mockClient := &MockHTTPClient{ 251 | Response: &http.Response{ 252 | StatusCode: http.StatusOK, 253 | Body: io.NopCloser(strings.NewReader(makeJSON(appts))), 254 | }, 255 | } 256 | mockNotifier := &MockNotifier{} 257 | url := "http://example.com" 258 | topic := "topic" 259 | beforeDate := time.Now().Add(72 * time.Hour) 260 | 261 | opts := CheckOptions{ 262 | EarliestMinutes: -1, 263 | LatestMinutes: 10 * 60, 264 | } 265 | 266 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, opts) 267 | 268 | if !mockNotifier.Called { 269 | t.Fatalf("Expected Notify to be called for appointment <= latest") 270 | } 271 | if mockNotifier.LocationID != 10 { 272 | t.Errorf("Expected LocationID 10 (09:30), got %d", mockNotifier.LocationID) 273 | } 274 | } 275 | 276 | func TestAppointmentCheck_BothValidRange(t *testing.T) { 277 | // earliest=08:00, latest=10:00; appointments at 07:30 (filtered), 09:00 (valid), 10:30 (filtered) 278 | appts := []Appointment{ 279 | {LocationID: 101, StartTimestamp: makeTS(1, 7, 30)}, 280 | {LocationID: 102, StartTimestamp: makeTS(1, 9, 0)}, 281 | {LocationID: 103, StartTimestamp: makeTS(1, 10, 30)}, 282 | } 283 | mockClient := &MockHTTPClient{ 284 | Response: &http.Response{ 285 | StatusCode: http.StatusOK, 286 | Body: io.NopCloser(strings.NewReader(makeJSON(appts))), 287 | }, 288 | } 289 | mockNotifier := &MockNotifier{} 290 | url := "http://example.com" 291 | topic := "topic" 292 | beforeDate := time.Now().Add(72 * time.Hour) 293 | 294 | opts := CheckOptions{ 295 | EarliestMinutes: 8 * 60, 296 | LatestMinutes: 10 * 60, 297 | } 298 | 299 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, opts) 300 | 301 | if !mockNotifier.Called { 302 | t.Fatalf("Expected Notify to be called for appointment within [earliest, latest]") 303 | } 304 | if mockNotifier.LocationID != 102 { 305 | t.Errorf("Expected LocationID 102 (09:00), got %d", mockNotifier.LocationID) 306 | } 307 | } 308 | 309 | func TestAppointmentCheck_BothInvalidRange_NoNotify(t *testing.T) { 310 | // earliest=12:00, latest=10:00, impossible to satisfy both; expect no notify 311 | appts := []Appointment{ 312 | {LocationID: 201, StartTimestamp: makeTS(1, 9, 0)}, 313 | {LocationID: 202, StartTimestamp: makeTS(1, 11, 30)}, 314 | {LocationID: 203, StartTimestamp: makeTS(1, 13, 0)}, 315 | } 316 | mockClient := &MockHTTPClient{ 317 | Response: &http.Response{ 318 | StatusCode: http.StatusOK, 319 | Body: io.NopCloser(strings.NewReader(makeJSON(appts))), 320 | }, 321 | } 322 | mockNotifier := &MockNotifier{} 323 | url := "http://example.com" 324 | topic := "topic" 325 | beforeDate := time.Now().Add(72 * time.Hour) 326 | 327 | opts := CheckOptions{ 328 | EarliestMinutes: 12 * 60, 329 | LatestMinutes: 10 * 60, 330 | } 331 | 332 | appointmentCheck(url, mockClient, mockNotifier, topic, beforeDate, opts) 333 | 334 | if mockNotifier.Called { 335 | t.Fatalf("Expected Notify NOT to be called when latest < earliest") 336 | } 337 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Global Entry Slot Notifier

2 | 3 |

4 | Global EntryGlobal EntryGlobal Entry 5 |

6 | 7 | **Note: This tool (global-entry-slot-notifier) is designed to be run locally—you'll need to download and run the binary manually with command-line options.** 8 | 9 | **If you're looking for a fully cloud-based, no-download solution, check out [global-entry-appointment](https://github.com/arun0009/global-entry-appointment), which lets you register for notifications directly via a [simple web page](https://arun0009.github.io/global-entry-appointment).** 10 | 11 | Global Entry Slot Notifier is a command-line tool that checks for available Global Entry appointment slots at specified locations 12 | and sends notifications via native system notification or to [ntfy app](https://ntfy.sh/), available on Android, iPhone, and Mac. 13 | 14 | ## Features 15 | 16 | - Periodically checks for available Global Entry appointment slots. 17 | - Sends notifications via system notification **or** ntfy app (using [ntfy.sh](https://ntfy.sh/)). 18 | - Configurable via command-line flags. 19 | 20 | ## Install 21 | 22 | 1. You can either build the binary using `go` (make sure you have go installed) with the `make` command 23 | 24 | ```bash 25 | make all 26 | ``` 27 | 28 | 2. Or Download the latest binary from the [releases](https://github.com/arun0009/global-entry-slot-notifier/releases) page 29 | as `global-entry-slot-notifier` (with `.exe` extension for windows) 30 | 31 | Note: You may need to grant permission to run the binary. 32 | 33 | ### Usage 34 | 35 | ```bash 36 | ./global-entry-slot-notifier -l -n [-t ] [-i ] [-b ] [-e ] [-L ] 37 | ``` 38 | 39 | ### Flags 40 | * `-l`, `--location` (required): Specify the [location ID](https://github.com/arun0009/global-entry-slot-notifier?tab=readme-ov-file#pick-your-location-id-from-below-to-use-in-flag-above) for the Global Entry appointment (see below location ids). 41 | * `-n`, `--notifier` (required): Specify the notifier type (app or system). 42 | * `-t`, `--topic` (required if notifier is app): Specify the ntfy.sh [topic](https://docs.ntfy.sh/) to send notifications to. 43 | * `-i`, `--interval` (optional): Specify the interval (in seconds, e.g. 30s) at which to check for available appointments. Default is 60s. 44 | * `-b`, `--before` (optional): Specify a cutoff date (YYYY-MM-DD) to only receive notifications for appointment slots before this date. 45 | * `-e`, `--earliest` (optional): Only appointments at/after this time (HH:MM, 24-hour). 46 | * `-L`, `--latest` (optional): Only appointments at/before this time (HH:MM, 24-hour). 47 | 48 | ### Examples 49 | 50 | 1. System Notification 51 | 52 | ```bash 53 | ./global-entry-slot-notifier -l 5446 -n system -i 90s -b 2025-12-30 54 | ``` 55 | 56 | 2. App Notification (first create your [topic on ntfy app](https://docs.ntfy.sh/)) 57 | 58 | ```bash 59 | ./global-entry-slot-notifier -l 5446 -n app -t my-ntfy-topic -b 2025-12-30 60 | ``` 61 | 62 | 3. With time window (only 9am–5pm) 63 | 64 | ```bash 65 | ./global-entry-slot-notifier -l 5446 -n app -t my-ntfy-topic -e 09:00 -L 17:00 66 | ``` 67 | 68 | ##### Pick your location id from below to use in flag (above) 69 | 70 | | ID | Enrollment Center Name | 71 | |------|--------------------------------------------------------| 72 | | 8040 | Albuquerque Enrollment Center - Albuquerque International Sunport 2200 Sunport Blvd SE Albuquerque NM 87106 | 73 | | 16759 | Alexandria Bay, NY - U.S. Port of Entry - GE ONLY - 46735 I-81 Alexandria Bay NY 13607 | 74 | | 7540 | Anchorage Enrollment Center - Ted Stevens International Airport 4600 Postmark Drive, RM NA 207 Anchorage AK 99502 | 75 | | 5182 | Atlanta International Global Entry EC - 2600 Maynard H. Jackson Jr. Int'l Terminal Maynard H. Jackson Jr. Blvd. Atlanta GA 30320 | 76 | | 16586 | Atlantic City Airport Global Entry Mobile Event - 101 Atlantic City International Airport Egg Harbor Township NJ 08234 | 77 | | 7820 | Austin-Bergstrom International Airport - 3600 Presidential Blvd. Austin-Bergstrom International Airport Austin TX 78719 | 78 | | 16611 | BAL-FO Harrisburg Enrollment Center - 1215 Manor Drive Suite 301 Mechanicsburg PA 17055 | 79 | | 16610 | BAL-FO Wilmington Delaware Enrollment Center - 908 Churchmans Road Ext, New New Castle DE 19720 | 80 | | 7940 | Baltimore Washington Thurgood Marshall Airport - Baltimore Washington Thurgood Marshall I Lower Level Door 18- Outer Street sign number 59 Linthicum MD 21240 | 81 | | 13321 | Blaine Global Entry Enrollment Center - 8115 Birch Bay Square St. Suite 104 Blaine WA 98230 | 82 | | 16734 | Blue Grass Airport 2024 - 4000 Terminal Drive Lexington KY 40510 | 83 | | 12161 | Boise Enrollment Center - 4655 S Enterprise Street Boise ID 83705 | 84 | | 5441 | Boston-Logan Global Entry Enrollment Center - Logan International Airport, Terminal E East Boston MA 02128 | 85 | | 14681 | Bradley International Airport Enrollment Center - International Arrivals Building/ Terminal B Bradley Airport Windsor Locks CT 06096 | 86 | | 5003 | Brownsville Enrollment Center - 700 Amelia Earhart Dr, Brownsville, Texas Brownsville South Padre Island International Airpo Brownsville TX 78521 | 87 | | 16705 | CFO - Louisville Intl Airport 2024 - Louisville Muhammad Ali International Airport 700 Administration Drive Louisville KY 40209 | 88 | | 5500 | Calais Enrollment Center - 3 Customs Street Calais ME 04619 | 89 | | 5006 | Calexico Enrollment Center - 1699 East Carr Road Calexico CA 92231 | 90 | | 16519 | Champlain Global Entry - 237 West Service Road Global Entry Only Champlain NY 12919 | 91 | | 5021 | Champlain NEXUS and FAST - 237 West Service Road NEXUS, FAST, and Global Entry Enrollment Center Champlain NY 12919 | 92 | | 14321 | Charlotte-Douglas International Airport - Charlotte-Douglas International Airport 5501 Josh Birmingham Parkway Charlotte NC 28208 | 93 | | 16781 | Chicago FO/ Wichita Airport - 2277 Eisenhower Airport Parkway Wichita KS 67209 | 94 | | 11981 | Chicago Field Office Enrollment Center - 610 S. CANAL STREET 6TH FLOOR CHICAGO IL 60607 | 95 | | 16657 | Chicago Mobile Event - 2700 INTERNATIONAL DRIVE WEST CHICAGO IL 60185 | 96 | | 5183 | Chicago O'Hare International Global Entry EC - 10000 West O'Hare Drive Terminal 5, Lower Level (Arrivals Floor) CHICAGO IL 60666 | 97 | | 7680 | Cincinnati Enrollment Center - 4243 Olympic Blvd. Suite. 210 Erlanger KY 41018 | 98 | | 9180 | Cleveland U.S. Customs and border protection - Customs & Border Protection 6747 Engle Road Middleburg Heights OH 44130 | 99 | | 5300 | Dallas-Fort Worth International Airport Global Entry - DFW International Airport - Terminal D First Floor, Gate D22-23 DFW Airport TX 75261 | 100 | | 16242 | Dayton Enrollment Center - 3800 Wright Drive Vandalia OH 45377 | 101 | | 16460 | Del Rio Enrollment Center - 3140 Spur 239 Del Rio TX 78840 | 102 | | 6940 | Denver International Airport - 8400 Denver International Airport Pena Boulevard Denver CO 80249 | 103 | | 5223 | Derby Line Enrollment Center - 107 I-91 South Derby Line VT 05830 | 104 | | 16461 | Des Moines GE Enrollment Center - 6100 Fleur Drive Des Moines IA 50321 | 105 | | 5023 | Detroit Enrollment Center Global Entry - 2810 W. Fort Street Suite 124 Detroit MI 48216 | 106 | | 5320 | Detroit Metro Airport - Detroit Evans Terminal 601 Rogell Dr., Suite 1271 Detroit MI 48242 | 107 | | 6920 | Doha International Airport - Hamad International Airport Doha | 108 | | 8100 | Douglas Enrollment Center - 1 Pan American Aveue (Cargo Facility) Douglas AZ 85607 | 109 | | 16755 | ERIE-Tom Ridge Field 2024 - 4411 West 12th Street Erie PA 16505 | 110 | | 16226 | Eagle Pass - 160 E. Garrison St. Eagle Pass TX 78852 | 111 | | 5005 | El Paso Enrollment Center - 797 S. Zaragoza Rd. Bldg. A El Paso TX 79907 | 112 | | 14381 | Fairbanks Enrollment Center - 6450 Airport Way - Suite 13 Room 1320A Fairbanks AK 99709 | 113 | | 16683 | Fargo Satellite Enrollment Center - 3803 20th Street North Fargo ND 58102 | 114 | | 5443 | Fort Lauderdale Global Entry Enrollment Center - 1800 Eller Drive Suite 104 Ft Lauderdale FL 33316 | 115 | | 16662 | Fort Lauderdale International Airport (Terminal 1) - 100 Terminal Drive Fort Lauderdale FL 33315 | 116 | | 9101 | Grand Portage - 9403 E Highway 61 Grand Portage MN 55605 | 117 | | 9140 | Guam International Airport - 355 Chalan PasaHeru Suite B 224-B Tamuning GU 96913 | 118 | | 14481 | Gulfport-Biloxi Global Entry Enrollment Center - Gulfport-Biloxi International Airport 14035 Airport Road, 2nd Floor (Main Terminal) Gulfport MS 39503 | 119 | | 5001 | Hidalgo Enrollment Center - Anzalduas International Bridge 5911 S. STEWART ROAD Mission TX 78572 | 120 | | 5340 | Honolulu Enrollment Center - 300 Rodgers Blvd Honolulu HI 96819 | 121 | | 5101 | Houlton POE/Woodstock - 27 Customs Loop Houlton ME 04730 | 122 | | 16793 | Houston Field Office GEEC - 2323 S. Shepherd Drive Houston TX 77019 | 123 | | 5141 | Houston Intercontinental Global Entry EC - 3870 North Terminal Road Terminal E Houston TX 77032 | 124 | | 16277 | Huntsville Global Entry Enrollment Center - Huntsville International Airport 1000 Glenn Hearn Blvd SW (Airport) Huntsville AL 35824 | 125 | | 14181 | International Falls Global Entry Enrollment Center - 3214 2nd Ave E International Falls MN 56649 | 126 | | 5140 | JFK International Global Entry EC - JFK International Airport Terminal 4, First Floor (Arrivals Level) Jamaica NY 11430 | 127 | | 12781 | Kansas City Enrollment Center - 1 Kansas City Boulevard Suite 30 Arrivals Level Kansas City MO 64153 | 128 | | 5520 | Lansdowne (Thousand Islands Bridge) - 860 Highway 137 Lansdowne ON K0E1L0 | 129 | | 5004 | Laredo Enrollment Center - 400 San Edwardo Laredo TX 780443130 | 130 | | 5360 | Las Vegas Enrollment Center - 5757 Wayne Newton Blvd Terminal 3 Las Vegas NV 89119 | 131 | | 5180 | Los Angeles International Global Entry EC - 11099 S LA CIENEGA BLVD SUITE 155 LOS ANGELES CA 90045 | 132 | | 13621 | Memphis Intl Airport Global Enrollment Center - 2491 Winchester Suite 230 Memphis TN 38116 | 133 | | 5181 | Miami International Airport - 2100 NW 42nd Ave Miami International Airport, Conc. "J" Miami FL 33126 | 134 | | 7740 | Milwaukee Enrollment Center - 4915 S Howell Avenue, 2nd Floor Milwaukee WI 53207 | 135 | | 6840 | Minneapolis - St. Paul Global Entry EC - 4300 Glumack Drive St. Paul MN 55111 | 136 | | 16282 | Mobile Regional Airport Enrollment Center - 8400 Airport Blvd Mobile AL 36608 | 137 | | 16672 | Moline-Quad Cities International Airport - 3300 69th avenue Moline IL 61265 | 138 | | 10260 | Nashville Enrollment Center - Airport Terminal Nashville TN 37214 | 139 | | 16709 | New Jersey Metro Area Mobile EC - 1180 1st Street New Windsor NY 12553 | 140 | | 9740 | New Orleans Enrollment Center - 1 Terminal Drive Kenner LA 70062 | 141 | | 16748 | New York Metro Area Mobile EC - ` ` ` NY 11530 | 142 | | 5444 | Newark Liberty Intl Airport - Newark Liberty International Airport Terminal B - Level 1 Entrance Area Newark NJ 07114 | 143 | | 5161 | Niagara Falls Enrollment Center - 2250 Whirlpool St Niagara Falls NY 14305 | 144 | | 16711 | Niagara Falls Enrollment Center GE Only - 2250 Whirlpool St Niagara Falls NY 14305 | 145 | | 5007 | Nogales, AZ - 200 N Mariposa Road, Suite B700 Nogales AZ 85621 | 146 | | 16555 | Norfolk EC - U.S. Customs House 101 E Main St Norfolk VA 23510 | 147 | | 16771 | Ogdensburg Enrollment Center - GE Only - 104 Bridge Approach Road Ogdensburg NY 13669 | 148 | | 16467 | Omaha, NE Enrollment Center - 3737A Orville Plaza Omaha NE 68110 | 149 | | 16674 | Ontario Intl Airport GE (California) - 2222 International Way International Terminal Ontario CA 91761 | 150 | | 5380 | Orlando International Airport - 10200 Jeff Fuqua Blvd. South Orlando, FL 32827 Orlando FL 32827 | 151 | | 5002 | Otay Mesa Enrollment Center - 9725 Via De La Amistad San Diego CA 92154 | 152 | | 15221 | Pembina Global Entry Enrollment Center - 10980 Interstate 29 N Pembina ND 58271 | 153 | | 11002 | Peoria international airport - 6100 W. Everett M. Dirksen Parkway International Terminal Peoria IL 61607 | 154 | | 5445 | Philadelphia International Airport - PHILADELPHIA INTL AIRPORT TERMINAL A WEST, 3RD FLOOR PHILADELPHIA PA 19153 | 155 | | 7160 | Phoenix Sky Harbor Global Entry Enrollment Center - 3400 E Sky Harbor Blvd, Terminal 4 CBP-Global Enrollment Center Phoenix AZ 85034 | 156 | | 9200 | Pittsburgh International Airport - 1000 Airport Boulevard Ticketing Level Pittsburgh PA 15231 | 157 | | 11841 | Port Clinton, Ohio Enrollment Center - 709 S.E. Catawba Road Port Clinton OH 43452 | 158 | | 5024 | Port Huron Enrollment Center - 2321 Pine Grove Avenue Port Huron MI 48060 | 159 | | 16699 | Port Huron Enrollment Center - RI - - FO MI 48226 | 160 | | 16661 | PortMiami - 1435 North Cruise Blvd Cruise Terminal D Miami FL 33132 | 161 | | 7960 | Portland, OR Enrollment Center - 8337 NE Alderwood Rd U.S. Customs and Border Protection Area Port Portland OR 97220 | 162 | | 14981 | Richmond, VA Enrollment Center - 5707 Huntsman Road, Suite 104 Ivor Massey Building Richmond VA 23250 | 163 | | 11001 | Rockford-Chicago International Airport - 50 Airport Drive Chicago Rockford International Airport Rockford IL 61109 | 164 | | 16475 | SEAFO - Bozeman Airport - 550 Wings Way Belgrade MT 59714 | 165 | | 16488 | SEAFO - Missoula International Airport - 5225 U.S. HIGWAY 10W Missoula MT 59808 | 166 | | 7600 | Salt Lake City International Airport - 3920 West Terminal Dr. #TCBP-1-060.2 Salt Lake City UT 84116 | 167 | | 7520 | San Antonio International Airport - 9800 Airport Boulevard, Suite 1101 San Antonio TX 78216 | 168 | | 16547 | San Diego International Airport EC - 3835 North Harbor Dr Terminal 2 West San Diego CA 92101 | 169 | | 5446 | San Francisco Global Entry Enrollment Center - International Arrival Level San Francisco CA 94128 | 170 | | 5400 | San Juan Global Entry Enrollment Center - Luis Muñoz Marin International Airport (SJU) Indoor Patio (La Placita de Aerostar) Carolina PR 00983 | 171 | | 16717 | San Juan Seaport - #1 La Puntilla Street San Juan PR 00901 | 172 | | 5460 | San Luis Enrollment Center - 1375 South Avenue E SLU II Global Enrollment Center San Luis AZ 85349 | 173 | | 5447 | Sanford Global Entry Enrollment Center - 1100 Red Cleveland Blvd Sanford FL 32773 | 174 | | 5080 | Sault Ste Marie Enrollment Center - 900 W Portage Ave 1st Floor Sault Ste. Marie MI 49783 | 175 | | 5420 | Seatac International Airport Global Entry EC - 17801 International Blvd, SeaTac, WA 98158 US Customs and Border Protection SeaTac WA 98158 | 176 | | 16723 | Shreveport Regional Airport - 5103 Hollywood Ave Shreveport LA 71101 | 177 | | 9040 | Singapore (Singapore, U.S. Embassy) - U.S. Embassy 27 Napier Road Singapore 258508 | 178 | | 16278 | South Bend Enrollment Center - South Bend International Airport 4501 Progress Drive South Bend IN 46628 | 179 | | 16693 | Special Event-Ontario Intl Airport(California)2024 - 2222 INTERNATIONAL WAY, INTERNATIONAL TERMINAL ONTARIO CA 91761 | 180 | | 16463 | Springfield – Branson National Airport, MO - 2300 N Airport Blvd Springfield MO 65802 | 181 | | 12021 | St. Louis Enrollment Center - 4349 WOODSON RD #201 ST. LOUIS MO 63134 | 182 | | 16809 | St. Thomas Cyril E. King Airport Enrollment Center - Lindbergh Bay St. Thomas VI 00802 | 183 | | 16251 | Sweetgrass Global Entry Enrollment Center - Nexus Enrollment Center 39825 Interstate 15 Sweetgrass MT 59484 | 184 | | 5120 | Sweetgrass NEXUS and FAST Enrollment Center - Nexus Enrollment Center 39825 Interstate 15 Sweetgrass MT 59484 | 185 | | 8020 | Tampa Enrollment Center - Tampa International Airport 4100 George J Bean Pkwy Tampa FL 33607 | 186 | | 16248 | Treasure Coast International Airport - 2990 Curtis King Blvd RM 122 Fort Pierce FL 34946 | 187 | | 16271 | Tri-cities Enrollment Center - Tri-Cities Airport 2525 TN-75 Blountville TN 37617 | 188 | | 9240 | Tucson Enrollment Center - 7081 S. Plumer Avenue Tucson AZ 85756 | 189 | | 6480 | U.S. Custom House - Bowling Green - 1 BOWLING GREEN NEW YORK NY 10004 | 190 | | 5060 | Warroad Enrollment Center - 41059 Warroad Enrollment Center State Hwy 313 N Warroad MN 56763 | 191 | | 16837 | Warwick RI Enrollment Center - 300 Jefferson Blvd, Suite 104 Warwick RI 02886 | 192 | | 5142 | Washington Dulles International Global Entry EC - 1 Saarinen Circle Main Terminal, Lower Level, near arrivals door 1 Sterling VA 20166 | 193 | | 8120 | Washington, DC Enrollment Center - 1300 Pennsylvania Avenue NW Washington DC 20229 | 194 | | 9260 | West Palm Beach Enrollment Center - West Palm Beach Enrollment Center 1 East 11th Street, Third Floor Riviera Beach FL 33404 | 195 | 196 | Note: If you don't find your location above, please look at the updated [list](https://ttp.cbp.dhs.gov/schedulerapi/locations/?temporary=false&inviteOnly=false&operational=true&serviceName=Global%20Entry 197 | ) 198 | 199 | ### Advanced 200 | 201 | You can download the binary on [raspberry pi](https://www.raspberrypi.com/) or on cloud e.g. [alwaysdata free tier](https://www.alwaysdata.com/en/) 202 | and run it as background process to notify you via [ntfy.sh](https://ntfy.sh/)) 203 | 204 | ```bash 205 | curl -L https://github.com/arun0009/global-entry-slot-notifier/releases/download/v1.0/global-entry-slot-notifier_1.0_linux_amd64 -o global-entry-slot-notifier 206 | ./global-entry-slot-notifier -l 5446 -n app -t my-ntfy-topic & 207 | ``` 208 | 209 | ### License 210 | This project is licensed under the MIT License. See the LICENSE file for details. 211 | 212 | ### Contributing 213 | Contributions are welcome! Please open an issue or submit a pull request for any changes. 214 | 215 | ### Contact 216 | For questions or feedback, please contact arun@gopalpuri.com --------------------------------------------------------------------------------