Unofficial Voi Scooters API

A row of several electric scooters parked together on the pavement.

The ongoing Electric Scooter Hire scheme has finally come to my city via Voi Scooters. I've always been a fan of these scooters and I've been making the most of using them.

I was curious to see if there was anything in the form of a public API, which was a long-shot, but it's worth a try. I thought that it would be cool to take inspiration and build something like the guy who made a map of all the broken McFlurry machines: McBroken.

Predictably, there wasn't a public API. But that doesn't mean there isn't some form of API. I took my first venture into the 'Man-in-the-middle' world and observed the network traffic coming from my iPhone using MITM.

Upon opening the Voi app, I was suddenly met with a lot of endpoints. Here are some particular ones of interest:

Get all nearby Scooters

This will get an array of all nearby scooters relative to your current location.

GET https://api.voiapp.io/v2/rides/vehicles?zone_id=[ZONE_ID]

Sample response:

{
    "data": {
        "vehicle_groups": [
            {
                "group_type": "scooter",
                "price_token": "[LONG JWT]",
                "vehicles": [
                    {
                        "battery": 86,
                        "category": "scooter",
                        "id": "[LONG_ID]",
                        "location": {
                            "lat": 1111,
                            "lng": 1111
                        },
                        "short": "[SHORT_ID]",
                        "zone_id": "[ZONE_ID]"
                    }
                ]
            }
        ]
    }
}

Check scooter status

Returns the status of a particular scooter. This is the API request that occurs when you're in the app and tap 'UNLOCK NOW' on a particular scooter, but before you confirm payment. The Short ID the the 4 digit registration number that appears on the side of each scooter.

POST https://api.voiapp.io/v3/rides/precheck/[SCOOTER_SHORT_ID]

Response:

{
    "pricePromise": "[LONG_JWT]",
    "vehicle": {
        "battery": 61,
        "category": "scooter",
        "id": "[LONG_SCOOTER_ID]",
        "location": {
            "latitude": 1111,
            "longitude": 1111
        },
        "locked": true,
        "name": "VOI",
        "shortId": "[SCOOTER_ID]",
        "status": "ready",
        "type": "como",
        "zone": "[ZONE_ID]"
    },
    "voucherDiscount": {
        "availability": "available"
    }
}

A ride is starting

The request that occurs when you have pressed 'UNLOCK NOW' and have confirmed that you're happy to pay.

The long scooter ID the one that appears as vehicle.id in the precheck, not the 4 digit license number.

POST https://api.voiapp.io/v3/rides/start/[LONG_SCOOTER_ID]

Response:

{
    "deviceToken": "[IOS_DEVICE_TOKEN]",
    "iosDeviceCheckToken": "[IOS_DEVICE_TOKEN_CHECK]",
    "location": {
        "latitude": 1111,
        "longitude": 1111,
        "radialAccuracy": 25,
        "timestamp": "[UNIX_TIMESTAMP]"
    },
    "pricePromise": "[LONG_JWT]"
}

Track your current ride

I think that this one only works if you have confirmed the unlocking of a Scooter and are currently paying for a ride. No additional data needed to be passed in the body so it may check the server to see if your account ID is currently associated with a scooter ID.

I didn't want to keep paying for a scooter that I wasn't using, so I ended this test ride pretty quickly. I assume that this particular request is to track yourself on the in-app map.

POST https://api.voiapp.io/v1/rides/track

Sample Response:

{
    "lat": 1111,
    "lon": 1111,
    "radial_accuracy": 11,
    "timestamp": "[UNIX_TIMESTAMP]"
}

Get ride history

Returns an array of your historical rides.

GET https://api.voiapp.io/v2/rides/history

{
    "data": [
        {
            "history": [
                {
                    "accountId": "[ACCOUNT_ID]",
                    "autoEnd": "",
                    "company": "VOI Techonology UK LTD",
                    "cost": 111,
                    "credit": 0,
                    "currency": "GBP",
                    "device_token": "[DEVICE_ID]",
                    "discount": 111,
                    "end": "[END_TIME]",
                    "endAddr": "[ADDRESS]",
                    "endLoc": [
                        1111,
                        111
                    ],
                    "firstLockAttempt": null,
                    "id": "[RIDE_ID]",
                    "lockCount": 0,
                    "manualEnd": false,
                    "minuteCount": 10,
                    "orgnr": "[ID]",
                    "paymentMethodBrand": "[BRAND]",
                    "paymentMethodInfo": "[4_DIGITS]",
                    "paymentProfileType": "personal",
                    "perMinuteCost": 111,
                    "pricePromise": "[JWT]",
                    "receiptLink": "[GOOGLE_LINK]",
                    "refundedDueToBadRide": false,
                    "startAddr": "[ADDRESS]",
                    "startCost": 111,
                    "startLoc": [
                        1111,
                        1111
                    ],
                    "startTime": "[TIMESTAMP]",
                    "success": true,
                    "transactionId": "",
                    "vat": 111,
                    "vatAmount": 45,
                    "vatFormatted": "20.0",
                    "vehicleId": "[SCOOTER_ID]",
                    "vehicleShort": "[SCOOTER_SHORT_ID]",
                    "zoneId": "[ZONE_ID]"
                }
            ]
        }
    ]
}

Other endpoints

I did find some other interesting endpoints, but didn't look into them too much:

GET https://api.voiapp.io/v1/feature-toggles/features // Feature flags (!)
GET https://api.voiapp.io/v1/user // Your profile
GET https://api.voiapp.io/v1/rides/zones // ALL zones
GET https://api.voiapp.io/v1/rides/zones?latitude=[LAT]&longitude=[LONG] // All zones relative to your current location
GET https://api.voiapp.io/v1/rides/zones/[ZONE_ID]/areas // Rideable areas in your current zone
GET https://api.voiapp.io/v2/rides/ride-plans?zone_id=[ZONE_ID] // Payment plans and loyalty schemes for the current area

Out of the above, I think that Feature Flags would be the most interesting one to dive into!

Authentication

Most (all?) of the requests above required additional x- headers to be passed, as seen below:

Voi app network headers

When performing the requests via Insomnia, x-access-token was the only one required to actually get an API response. I assume that the others are more for further data-validation and additional analytics.

As I was tracking these legitimate requests coming from my phone, it meant that I was able to make further requests in Insomnia by copy-pasting the Access Token into the required fields. This is great for tinkering, but what if I wanted something more persistent for later use?

When you log into the app, a new session is authenticated via the endpoint below:

POST https://api.voiapp.io/v1/auth/session

With the body:

{
    "authenticationToken": "[AUTH_TOKEN]"
}

This request returns both an access and authentication tokens. This access token is then passed an x-access-token header for all later requests.

Each token looked to be Base64 encoded - which I verified by entering one into a decoding site. I learned that Voi are using JSON Web Tokens for their authentication. If you do this yourself, you'll see that it encodes user ID, device ID, expiry time, refresh time, among other things.

It was through this method that I found out tokens last for a mere 15 minutes before requiring a refresh.

In practice, this means that I'd only have a small window of 15 minutes to make requests in Insomina (or any other custom app) before needing to interact with the phone app again to get a new token.

To be able to refresh this token without the app, I'd would need the original authentication token from when I originally registered my account. I didn't know of any way to retroactively get this token - so there went my plan of building something fun with this API!

It wasn't until a few days later when writing this blog post I discovered that some clever people over on GitHub have already solved this problem and posted methods to perform the entire registration process via the API.

By registering an account directly through the API (rather than the app) - you'd be able to get the correct tokens returned to you and be allowed to refresh access down the line.

I did verify this by performing that registration flow and was successfully able to refresh access to the API and have the account fully-functioning within the app!

It's worth noting that Voi does seem to have increased security for the app:

  • Registering via the API requires a legitimate phone number for SMS verification
  • Some endpoints will require you to have validated your account with photo ID
  • You're limited to one device per-session. Starting a new session via Insomnia will end the session on your mobile device and visa versa

In practice, this means that if you were to use your main account's access token to power a third-party app, you'll constantly be logged out of your phone whenever a new API request is made. To make an app with this unofficial API, I believe you'd need to:

  • Have a spare phone number
  • Have the ability to receive SMS on this number
  • Have a phone that you can put this SIM into to validate your Photo ID on Voi

This culminates in what's essentially creating a validated burner-Voi account that will be verified once and then only used for API requests outside of the official app.

Some bad news

Because I'm an idiot, I used my real phone number to register a second account with the method above (to see if it worked). This number is already tied to my primary Voi account - but because I performed the SMS verification flow for a new email address, any time I login to the app with my phone number, I'm logged into my second test account and am unable to get back into my original account.

I've contacted support, so hopefully this can get straightened out!

15/07 Update: I have access to my account again! Contacting the people over at Voi support were very quick and helpful.