Road Ready NZ API
Get started
Check the Road Ready code a traveller gives you when they book, from your own booking system. You need one key and one call.
- Create a key On your dashboard, under API Keys.
- Keep it on your server Send it with every call.
- Verify the code Post the code and your Agency ID.
| Environment | Base URL |
|---|---|
| Production | https://roadreadynz.com/api |
| Staging | https://staging.roadreadynz.com/api |
Staging and production are separate: each has its own keys, and an agency on staging exists only on staging. The switch at the top fills the base URL you choose into every example.
Your API key
Create the key on your dashboard, under API Keys. It verifies Road Ready codes for your agency, and tests your own completion webhook if you have one. Send it on every call, in either header:
Authorization: ApiKey <key>
X-API-Key: <key>Verify a Road Ready code
/partner/booking-codes/verifyYour agency keyChecks the code a traveller gives you when they book. Verifying does not use the code up: you can verify the same code again, for example at booking and again at pickup.
Request body
| Field | Notes |
|---|---|
code required |
The code exactly as the traveller gave it. We normalise it for you (see "How a code is read" below). |
companyId required |
Your Agency ID, shown on your dashboard. Not case-sensitive. |
curl -X POST "https://roadreadynz.com/api/partner/booking-codes/verify" \
-H "Authorization: ApiKey $ROAD_READY_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "7K2M9Q", "companyId": "JIM" }'{
"isValid": true,
"codeRecord": {
"code": "7K2M9Q",
"status": "active",
"createdAt": "2026-09-27T01:02:03.000Z",
"expiresAt": "2027-09-27T01:02:03.000Z"
}
}Response fields
| Field | Notes |
|---|---|
codeRecord |
Holds these four fields and no others. |
createdAt |
When the code was issued for this agency. |
expiresAt |
The last moment the code verifies: 365 days after it was first issued, so for a code carried over from another agency it comes sooner than createdAt plus 365 days. Always there; null only for a code with no recorded issue date, which does not expire. |
Errors
| Status | When | What to do |
|---|---|---|
| 400 | Not six characters of the code alphabet. | Ask the traveller to check the code. |
| 404 | Not issued for your agency, or mistyped. | Ask the traveller to check the code. |
| 410 | Issued more than 365 days ago. | The traveller takes the guide's quiz again and gets a new code. |
| 400 | companyId missing or invalid. |
Send your Agency ID. |
| 401 | The key is wrong or revoked, or companyId is not your Agency ID. |
Check the key and the Agency ID. |
| 429 | Over a rate limit. | Wait for Retry-After. |
Exact error bodiesverify keeps its own
The verify call keeps the error bodies it has always had, without code, except for 410 and
429. Every other call answers errors as { "error": "...", "code": "..." }.
400 { "isValid": false, "error": "Invalid booking code" }
404 { "isValid": false, "error": "Booking code not found in system" }
410 { "isValid": false, "error": "Booking code has expired", "code": "booking_code_expired" }
400 { "error": "Invalid or missing companyId" }
401 { "error": "Invalid API key" }
403 { "error": "Partner account is suspended" }
429 { "error": "Too many requests. Retry after 40 seconds.", "code": "rate_limited" }403 is answered to booking platforms only.
How a code is readalphabet and normalisation
A Road Ready code is six characters of this alphabet (Crockford's Base32): the ten digits and the
letters A to Z without I, L, O and U.
0123456789ABCDEFGHJKMNPQRSTVWXYZCodes are typed by hand, so before a code is checked it is normalised, in this order:
- spaces at both ends are removed;
- it is converted to upper case;
IandLare read as1, andOas0.
A code that is then not six characters of the alphabet answers 400. U is not in the alphabet and
is not read as another character, and a space or hyphen inside the code is not removed: both answer
400. Send the code as the traveller gave it. If you check it yourself first, apply the same three
steps and test the result against ^[0-9A-HJKMNP-TV-Z]{6}$.
Codes from another agency's guidecarry-over and expiry
A traveller who has completed the guide once gets the same Road Ready code for every agency whose link they open afterwards in the same browser, without watching the video again. The code verifies for each of those agencies.
A code is valid for 365 days from when it was first issued. A code carried over to another agency
keeps its first issue date, so carrying it over does not extend it. An older code answers 410 with
code booking_code_expired: the traveller takes the guide's quiz again and gets a new code.
Older pathstill works
POST /booking-codes/verify is the same call under its earlier path and keeps working.
Rate limits
Every call counts against your key, per calendar minute (UTC); counts start again at the start of
each minute. A call over a limit is answered with 429 and a Retry-After header, the number of
seconds until the next minute starts. The body names the limit in code, on the verify call as
well: { "error": "Too many requests. Retry after 40 seconds.", "code": "rate_limited" }.
| Limit | Per minute |
|---|---|
| Calls with your key, all calls together | 120 |
Failed verifications with your key (400 Invalid booking code, 404 or 410) |
10 |
| Calls without a valid key (missing, wrong, revoked or expired key, an Agency ID that is not yours), per IP address | 30 |
Completion webhook
We tell your system the moment a traveller has a Road Ready code, so the booking is marked without anyone typing the code.
Who can have one:
- A booking platform, for all its agencies: one webhook per platform, with the platform key for the calls below.
- An agency with its own booking system and no booking platform: one webhook for that agency, with the agency's own key. An agency on a booking platform has none of its own; its events go to the platform's webhook, or nowhere if the platform has none.
Every event goes to one receiver only. The request, the signature and the rules below are the same for both.
It is sent only when the traveller opened the guide through a link that carried your booking
reference as ref. A traveller who scans the QR poster or opens the short link has no ref, so
nothing is sent for them: verify their code as before. The link may be the guide link or the
agency's shortLink with ref added, and both take lang to choose the guide's language:
https://roadreadynz.com/guide?c=<code>&ref=<your reference>
https://roadreadynz.com/guide?c=<code>&lang=<language>&ref=<your reference>https://roadreadynz.com/?c=<code>&ref=<your reference>Handling an event
- Check the signature Before anything else, over the raw bytes exactly as they arrived (not over a body you parsed and serialised again), and compare in constant time. See Checking the signature.
- Match the booking Look the booking up by
refand check thatcompanyIdis the agency of that booking's operator. Anyone can changecin a link, so an event can carry another agency's code with your reference. - Verify the code Verify
codefor that agency with Verify a Road Ready code. The code is valid the moment the webhook arrives. This also makes a replayed request harmless. - Answer 2xx Also for a
refyou do not know, and for aneventIdyou have already handled (store the ones you have), or we keep sending it.
Staff can always verify a code by hand; the webhook does not replace that.
The request
your URLFrom Road Ready{
"code": "ABC234",
"companyId": "JMY",
"ref": "0b9f6c1e-7a52-4d0e-9c53-2f1f6f0a8f11",
"completedAt": "2026-10-13T01:02:03.000Z",
"eventId": "evt_0123456789abcdef0123456789abcdef"
}Content-Type: application/json
X-RoadReady-Signature: sha256=<HMAC-SHA256 of the raw body, lower-case hex>
X-RoadReady-Event-Id: evt_0123456789abcdef0123456789abcdef
X-RoadReady-Timestamp: 2026-10-13T01:02:05.000Z
User-Agent: RoadReadyNZ-Webhook/1| Field | Notes |
|---|---|
code |
The traveller's Road Ready code. |
companyId |
The agency code the guide was opened for (the c of the link). |
ref |
Your reference from the link. Always present. |
completedAt |
When the traveller got the code or, with a code they already held, opened your link. ISO 8601 in UTC. |
eventId |
Identifies this event. The same on every repeat of it. |
| Header | Notes |
|---|---|
X-RoadReady-Signature |
sha256= and the HMAC-SHA256 of the raw request body with your signing secret, in lower-case hex. |
X-RoadReady-Event-Id |
The body's eventId. |
X-RoadReady-Timestamp |
When this attempt was sent, ISO 8601 in UTC. For your logs; it is not part of the signature. |
User-Agent |
RoadReadyNZ-Webhook/1 |
Ignore fields you do not know: we may add some.
Checking the signature
import { createHmac, timingSafeEqual } from "node:crypto";
function isFromRoadReady(rawBody /* Buffer */, signatureHeader, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const given = Buffer.from(signatureHeader ?? "");
return given.length === expected.length && timingSafeEqual(given, Buffer.from(expected));
}Test vectorcheck your code against a known signature
The body
{"code":"ABC234","companyId":"JMY","ref":"0b9f6c1e-7a52-4d0e-9c53-2f1f6f0a8f11","completedAt":"2026-10-13T01:02:03.000Z","eventId":"evt_0123456789abcdef0123456789abcdef"}
with the secret test-secret-not-a-real-one gives
sha256=095c735e8adb4170be60b62dbf2c5c1b01af2bd7c869874870989b8c8a676d9a.
Your answer and retries
Any 2xx means you have it; we do not read the response body. Anything else is a failed attempt and
is sent again: 403, 503, every other 4xx and 5xx, a redirect (we do not follow redirects), no
answer within 10 seconds, or a connection that fails. After a failed attempt we try again:
- 1 min after
- 5 min
- 15 min
- 1 h
- every hour
- 72 h stop
After 72 hours we stop, unless we send it again by hand after a problem on either side is fixed.
Every repeat has the same eventId and the same body. The same booking may also be reported by two
events with different codes, for example when two drivers of one booking each complete the guide.
When your endpoint keeps failingthe pause
If your endpoint fails every attempt for 30 minutes or more (at least 5 in a row), we pause: we send
one waiting event about once an hour instead of each one on its own schedule, and once you answer it
with 2xx the rest follow within minutes, a few at a time. A pause loses nothing, and the 72 hours
still apply; an event still waiting at its 72 hour mark is given up with
lastError: receiver_paused.
When events are sent
One event is sent for each ref, whichever way the traveller comes to hold a code for that agency:
- they pass the quiz. The browser remembers the
refof the link for 30 days, so a traveller who opens it today and finishes the guide tomorrow in the same browser is still reported. The guide removesreffrom the address once it has it, so do not expect to see it there; - they already hold a valid code from another agency's guide, which then counts for this agency as well: the event is sent when they open your link;
- they already hold a valid code for this agency (a second booking with the same operator, or the
email opened again): the event is sent when they open your link, with the code they hold. A
refwe have reported is not reported again.
Daily cap per traveller10 refs per traveller and agency in 24 hours
For one traveller and agency we report at most 10 different refs in 24 hours; a link beyond that
opens the guide as usual, and nothing is sent for it. This limits one traveller (one browser), not
the number of events for an agency: anyone who has a link can complete the guide, so your endpoint
must not rely on it to bound what arrives. Check each event as described above, and tell us if you
see events you cannot explain: we can switch your webhook off at once.
Test your endpoint
/partner/webhook/testYour agency keyOnce your webhook is set up, sends a test event to it, whenever you like, on staging and on production.
Request body
| Field | Notes |
|---|---|
agencyCode required |
Your Agency ID. It tells us which agency the key belongs to, and becomes the event's companyId. |
curl -X POST "https://roadreadynz.com/api/partner/webhook/test" \
-H "Authorization: ApiKey $ROAD_READY_KEY" \
-H "Content-Type: application/json" \
-d '{ "agencyCode": "JMY" }'{ "eventId": "evt_5d41402abc4b2a76b9719d911017c592" }We then send the event like any other: the same body, signature, retries and time-out. Only these tell it apart:
| Test event | |
|---|---|
ref |
Starts with roadready-test-, then random characters. No guide link can carry this prefix, so a real event never has it. |
code |
Always TEST00. It is never issued to a traveller and never verifies (404). |
X-RoadReady-Test |
Header 1, sent only with a test event. |
Every test is a new event with its own eventId. While deliveries to you are paused (see
Your answer and retries), a test event waits like any other: it shows
pending with attempts: 0 until it is the hour's probe or the pause ends, so a fixed endpoint is
found within the hour.
Errors
| Status | code |
When |
|---|---|---|
| 400 | invalid_request |
The body is not a JSON object with only agencyCode, whichever key. |
| 401 | invalid_api_key |
An agency key without agencyCode, or with another agency's code. |
| 409 | webhook_not_configured |
No webhook is set up for you in that environment, also for an agency whose events go to its booking platform. Ask us. |
| 409 | webhook_disabled |
Your webhook is switched off. Ask us. |
| 429 | test_limit_exceeded |
5 test events per receiver (platform or agency) and clock hour (UTC) already; the sixth waits for Retry-After, the seconds until the next hour. |
Both webhook calls also count against your rate limits. Errors are answered as
{ "error": "...", "code": "..." }: handle code; the wording of error can change.
Follow a delivery
/partner/webhook/events/{eventId}?agencyCode=<Agency ID>Your agency keyShows how the delivery of any of your events is going, a test or a real one. Add your Agency ID as
agencyCode.
curl "https://roadreadynz.com/api/partner/webhook/events/evt_5d41402abc4b2a76b9719d911017c592?agencyCode=JMY" \
-H "Authorization: ApiKey $ROAD_READY_KEY"{
"eventId": "evt_5d41402abc4b2a76b9719d911017c592",
"test": true,
"status": "pending",
"attempts": 1,
"lastStatus": 503,
"lastError": null,
"createdAt": "2026-10-14T01:00:00.000Z",
"lastAttemptAt": "2026-10-14T01:00:01.000Z",
"nextAttemptAt": "2026-10-14T01:01:01.000Z",
"deliveredAt": null
}| Field | Notes |
|---|---|
status |
pending (being sent, or waiting for the next attempt), delivered (you answered 2xx) or dead (given up after 72 hours). |
attempts |
Attempts made so far. |
lastStatus |
The HTTP status your endpoint answered last, or null. |
lastError |
Why the last attempt got no status, or null: timeout (no answer within 10 seconds), network (no connection), url_refused (your URL is not acceptable), webhook_off (your webhook is switched off), receiver_paused (given up after 72 hours while deliveries to you were paused), or a fault on our side (no_secret, secret_unavailable, lookup_failed), which we see as well. |
nextAttemptAt |
When the next attempt is due; null once delivered or dead. |
The answer never contains code or ref. An event that is not yours answers 404
event_not_found, the same as an unknown id.
Questions, or need a limit raised? Contact us.
Get started
Create a Road Ready agency for each of your operators and show its short link and QR poster in your own product. When travellers book, check their codes for any of your agencies with the same key.
- Get your key We issue one per environment. Build against staging first.
- Create the agency Store its
codenext to yourexternalId. - Show link and poster Or embed the guide in your booking page.
- Check codes Verify them, or let the webhook tell you.
| Environment | Base URL |
|---|---|
| Production | https://roadreadynz.com/api |
| Staging | https://staging.roadreadynz.com/api |
Staging and production are separate: each has its own key, and an agency created on staging exists only on staging. The switch at the top fills the base URL you choose into every example.
| Call | Does |
|---|---|
POST /partner/agencies |
Create an agency |
GET /partner/agencies/{code} |
Read an agency and its poster |
GET /partner/agencies |
List your agencies |
PATCH /partner/agencies/{code} |
Change name, logo or contact email |
POST /partner/agencies/{code}/qr/retry |
Render the poster again |
POST /partner/booking-codes/verify |
Verify a code for one of your agencies |
POST your URL |
Completion webhook (we call you) |
POST /partner/webhook/test |
Send a test event |
GET /partner/webhook/events/{eventId} |
Follow a delivery |
Your platform key
We issue it when we set up your platform account. It works for every call on this tab, for every agency created with it. Send it on every call, in either header:
Authorization: ApiKey <key>
X-API-Key: <key>Agencies
Create an agency
/partner/agenciesPlatform keyCreates the Road Ready agency of one of your operators.
Request body
| Field | Notes |
|---|---|
agencyName required |
1 to 120 characters. Shown to travellers. |
externalId required |
Your own id for the operator, 1 to 128 characters. Unique per platform account. |
contactEmail optional |
Stored for reference. We send no email to it. |
logoUrl optional |
https URL of a PNG, JPEG, WebP or GIF, 5 MB at most. We fetch it once and keep our own copy. See Logo requirements. |
curl -X POST "https://roadreadynz.com/api/partner/agencies" \
-H "Authorization: ApiKey $ROAD_READY_KEY" \
-H "Content-Type: application/json" \
-d '{
"agencyName": "Jimny Rentals",
"externalId": "op_1234",
"contactEmail": "ops@jimny.example",
"logoUrl": "https://cdn.partner.example/jimny.png"
}'{
"agencyName": "Jimny Rentals",
"contactEmail": "ops@jimny.example",
"externalId": "op_1234",
"code": "JIM",
"shortLink": "https://roadreadynz.com/?c=JIM",
"qrUrl": null,
"qrStatus": "pending",
"active": true
}codeidentifies the agency in every other call, verify included. Store it next to yourexternalId.shortLinkworks as soon as the call returns. The QR poster is rendered in the background; see Read an agency and its poster.activeistrue; see Inactive agencies.
Errors
invalid_request, logo_invalid, logo_fetch_failed, agency_inactive (a repeat for an inactive
agency), quota_exceeded (more new agencies in 24 hours than your daily limit). See
Error codes.
Read an agency and its poster
/partner/agencies/{code}Platform keyReturns the same object as create. Poll it after a create or a logo change until the poster is ready: every 2 to 5 seconds, and stop after 2 minutes. It is normally ready within 60 seconds.
curl "https://roadreadynz.com/api/partner/agencies/JIM" -H "Authorization: ApiKey $ROAD_READY_KEY"qrStatus |
Meaning |
|---|---|
pending |
The poster is being rendered. Ask again in a few seconds. |
ready |
qrUrl is the poster, a PNG (1748 x 2480) with the QR code that opens shortLink. |
failed |
Rendering failed. The agency and shortLink still work. Render it again; if it fails again, contact us. |
You can only read agencies created with your key. Any other code returns 404 with
agency_not_found.
List your agencies
/partner/agenciesPlatform keyLists the agencies of your platform account, newest first, for example to re-sync your database.
Query parameters
| Parameter | Notes |
|---|---|
limit optional |
Agencies per page, 1 to 100. Default 50. |
cursor optional |
nextCursor of the previous page. Leave it out for the first page. |
externalId optional |
Your id for one operator. Returns that agency, or an empty list. Cannot be combined with cursor. |
curl "https://roadreadynz.com/api/partner/agencies?limit=100" -H "Authorization: ApiKey $ROAD_READY_KEY"{
"agencies": [
{
"agencyName": "Jimny Rentals",
"contactEmail": "ops@jimny.example",
"externalId": "op_1234",
"code": "JIM",
"shortLink": "https://roadreadynz.com/?c=JIM",
"qrUrl": "https://firebasestorage.googleapis.com/...",
"qrStatus": "ready",
"active": true
}
],
"nextCursor": "eyJjIjoiSklNIn0"
}- Each agency is the same object as
GET /partner/agencies/{code}returns. - While more agencies exist,
nextCursoris a string: pass it unchanged ascursorto get the next page. On the last page it isnull. Treat it as opaque; its content can change. - A platform account without agencies gets
200 OKwith"agencies": []. - To find the agency of one operator,
GET /partner/agencies?externalId=op_1234is quicker than paging.
Errors
invalid_request for a limit outside 1 to 100, or a cursor we did not issue to you.
Change name, logo or contact email
/partner/agencies/{code}Platform keyChanges one or more fields of an agency and returns it as it is after the change (200 OK). code
and shortLink never change, so QR codes already printed keep working.
Request body
| Field | Notes |
|---|---|
agencyName optional |
1 to 120 characters. |
logoUrl optional |
New logo, same rules as on create (see Logo requirements). null removes the logo. |
contactEmail optional |
Stored for reference, as on create. We send no email to it. null or an empty string removes it. Only for agencies you created; see below. |
Send at least one of them. Any other field (code, shortLink, externalId, ...) is refused with
invalid_request.
curl -X PATCH "https://roadreadynz.com/api/partner/agencies/JIM" \
-H "Authorization: ApiKey $ROAD_READY_KEY" \
-H "Content-Type: application/json" \
-d '{ "logoUrl": "https://cdn.partner.example/jimny-2026.png" }'Errors
invalid_request, logo_invalid and logo_fetch_failed (nothing is changed, the name included),
agency_not_found (not one of your agencies), agency_inactive, contact_email_not_changeable. See
Error codes.
Contact email of agencies that signed up themselves409 contact_email_not_changeable
contactEmail can be changed for agencies you created with POST /partner/agencies. An agency that
signed up with us itself and was connected to your account later signs in to its Road Ready
dashboard with that address, so for it a request with contactEmail returns 409 with
contact_email_not_changeable and changes nothing, the name and logo included. Its name and logo can
still be changed in a request without contactEmail.
What counts as a changeno-ops and failed logos
A request that changes nothing (the same name, or null for an agency without a logo) answers 200
and changes nothing. A logoUrl is always fetched again and always renders a new poster. If the new
logo fails the checks (logo_invalid, logo_fetch_failed), nothing is changed, the name included.
Render the poster again
/partner/agencies/{code}/qr/retryPlatform keyFor a poster whose qrStatus is failed, or one that has been pending for more than 5 minutes. No
request body.
curl -X POST "https://roadreadynz.com/api/partner/agencies/JIM/qr/retry" -H "Authorization: ApiKey $ROAD_READY_KEY"200 OK with the agency object and its current qrStatus:
- A poster that is
pendingand started less than 5 minutes ago is being rendered; the call changes nothing and answerspending. - Any other poster,
readyincluded, is rendered again and the call answerspending. Poll as after a create.
Errors
agency_not_found for a code that is not one of your agencies, agency_inactive for an inactive
agency. See Error codes.
Verify a Road Ready code
/partner/booking-codes/verifyPlatform keyOne platform key verifies the codes a traveller gives you for all your agencies. Send the agency's
code from create as companyId. Verifying does not use the code up: you can verify the same code
again, for example at booking and again at pickup.
Request body
| Field | Notes |
|---|---|
code required |
The code exactly as the traveller gave it. We normalise it for you (see "How a code is read" below). |
companyId required |
The agency's code from create. Not case-sensitive. |
curl -X POST "https://roadreadynz.com/api/partner/booking-codes/verify" \
-H "Authorization: ApiKey $ROAD_READY_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "7K2M9Q", "companyId": "JIM" }'{
"isValid": true,
"codeRecord": {
"code": "7K2M9Q",
"status": "active",
"createdAt": "2026-09-27T01:02:03.000Z",
"expiresAt": "2027-09-27T01:02:03.000Z"
}
}Response fields
| Field | Notes |
|---|---|
codeRecord |
Holds these four fields and no others. |
createdAt |
When the code was issued for this agency. |
expiresAt |
The last moment the code verifies: 365 days after it was first issued, so for a code carried over from another agency it comes sooner than createdAt plus 365 days. Always there; null only for a code with no recorded issue date, which does not expire. |
Errors
| Status | When | What to do |
|---|---|---|
| 400 | Not six characters of the code alphabet. | Ask the traveller to check the code. |
| 404 | Not issued for this agency, or mistyped. | Ask the traveller to check the code. |
| 410 | Issued more than 365 days ago. | The traveller takes the guide's quiz again and gets a new code. |
| 400 | companyId missing or invalid. |
Send the agency's code. |
| 401 | The key is wrong, or companyId is not one of your agencies. |
Use the code from create. |
| 403 | Your platform account is suspended. | Contact us. |
| 429 | Over a rate limit, for this agency or for the whole key. | Wait for Retry-After. |
Exact error bodiesverify keeps its own
The verify call keeps the error bodies it has always had, without code, except for 410 and
429. Every other call answers errors as { "error": "...", "code": "..." }.
400 { "isValid": false, "error": "Invalid booking code" }
404 { "isValid": false, "error": "Booking code not found in system" }
410 { "isValid": false, "error": "Booking code has expired", "code": "booking_code_expired" }
400 { "error": "Invalid or missing companyId" }
401 { "error": "Invalid API key" }
403 { "error": "Partner account is suspended" }
429 { "error": "Too many requests. Retry after 40 seconds.", "code": "rate_limited" }403 is answered to booking platforms only.
How a code is readalphabet and normalisation
A Road Ready code is six characters of this alphabet (Crockford's Base32): the ten digits and the
letters A to Z without I, L, O and U.
0123456789ABCDEFGHJKMNPQRSTVWXYZCodes are typed by hand, so before a code is checked it is normalised, in this order:
- spaces at both ends are removed;
- it is converted to upper case;
IandLare read as1, andOas0.
A code that is then not six characters of the alphabet answers 400. U is not in the alphabet and
is not read as another character, and a space or hyphen inside the code is not removed: both answer
400. Send the code as the traveller gave it. If you check it yourself first, apply the same three
steps and test the result against ^[0-9A-HJKMNP-TV-Z]{6}$.
Codes from another agency's guidecarry-over and expiry
A traveller who has completed the guide once gets the same Road Ready code for every agency whose link they open afterwards in the same browser, without watching the video again. The code verifies for each of those agencies.
A code is valid for 365 days from when it was first issued. A code carried over to another agency
keeps its first issue date, so carrying it over does not extend it. An older code answers 410 with
code booking_code_expired: the traveller takes the guide's quiz again and gets a new code.
Older pathstill works
POST /booking-codes/verify is the same call under its earlier path and keeps working.
Rate limits
Every call counts against your key, per calendar minute (UTC); counts start again at the start of
each minute. A call over a limit is answered with 429 and a Retry-After header, the number of
seconds until the next minute starts. The body names the limit in code, on the verify call as
well: { "error": "Too many requests. Retry after 40 seconds.", "code": "rate_limited" }.
| Limit | Per minute |
|---|---|
| Calls with your key, all calls together | 120 |
Failed verifications (400 Invalid booking code, 404 or 410), per agency |
10 |
| Failed verifications, all your agencies together | 60 |
| Calls without a valid key (missing, wrong, revoked or expired key, suspended platform account, agency that is not yours), per IP address | 30 |
New agencies (quota_exceeded), in 24 hours |
50 unless agreed otherwise |
One key verifies for all your agencies, so failed verifications are counted for each agency
(companyId) separately. Once one agency has used up its 10, verify calls for that agency answer
429, correct codes included, until the minute is over; your other agencies keep verifying. Wrong
codes typed at one operator's counter therefore do not stop the others. Only when all your agencies
together reach 60 failed verifications in a minute does every verify call with the key answer 429.
A 429 looks the same in both cases; Retry-After is the time to wait for that agency, or for the
key.
A verify call for a companyId that is not one of your agencies is answered with 401 and counts as
a call without a valid key (30 a minute per IP address), not against an agency.
Creating and reading agencies are not held up by failed verifications, only by the 120 calls. A 429
from a create, change or poster retry is recorded under its X-Request-Id like every other one. It
is separate from quota_exceeded, the daily limit of new agencies.
Sending travellers to the guide
Guide language
shortLink opens the guide in the language of the traveller's browser. To choose the language
yourself, for example from the traveller's licence country, add lang to shortLink:
https://roadreadynz.com/?c=JIM&lang=ja-JPen-USEnglishes-ESSpanishpt-BRPortuguese (Brazil)fr-FRFrenchde-DEGermansmSamoantoTonganfil-PHFilipinozh-CNChinese (Simplified)zh-TWChinese (Traditional)ja-JPJapaneseko-KRKoreanth-THThaiar-SAArabic
Other forms of langregional codes, fallback, the traveller's choice
langalso takes any case and a bare or regional language, as the embedded guide does:ja,en-GB,pt-PT,zh-Hant(Traditional Chinese),tl(Filipino).- A
langwe do not offer opens the guide in the language of the traveller's browser (English if we do not offer that either). - The traveller can change the language on the guide's first screen. A browser that has started the
guide before keeps the language chosen then, whatever
langsays.
Embed the guide
Instead of sending travellers to the short link, you can show the guide inside your own booking
page. When the traveller finishes the quiz, the frame hands the Road Ready code to your page: the
traveller copies, types or emails nothing. The frame's address is
https://roadreadynz.com/embed/<code>?lang=<language>, with the agency's code.
<iframe id="road-ready" src="https://roadreadynz.com/embed/JIM?lang=en"
allow="clipboard-write" style="width: 100%; height: 720px; border: 0"></iframe>
<script>
const ROAD_READY = "https://roadreadynz.com";
const frame = document.getElementById("road-ready");
window.addEventListener("message", (event) => {
// Only messages from the guide's frame.
if (event.origin !== ROAD_READY || event.source !== frame.contentWindow) return;
const message = event.data;
if (message.type === "roadready:ready") {
// Optional: ties the code to this booking; it comes back as `ref`.
frame.contentWindow.postMessage({ type: "roadready:hello", ref: "BK-1042" }, ROAD_READY);
}
if (message.type === "roadready:completed") {
saveBookingCode(message.ref, message.code).then(() => {
// Tell the traveller they are done.
frame.contentWindow.postMessage({ type: "roadready:received", code: message.code }, ROAD_READY);
});
}
});
</script>frame → page roadready:ready The guide has loaded.
page → frame roadready:hello Optional. { ref }: a string of at most 128 characters.
From then on the frame posts only to your page and
adds ref to completed.
frame → page roadready:completed The traveller has a Road Ready code.
{ code, companyId, completedAt, ref }: completedAt is the
ISO time the frame sent it; ref only if you sent one.
page → frame roadready:received Required. { code }: the code you stored.
See it working. Our demo booking page
(staging) frames the guide, receives the code when the quiz is passed, confirms it and verifies it.
The one-minute video shows it, then the same without a
confirmation, where the traveller sees their code after 5 seconds.
The messages in detailconfirmation, repeats, origins
- The frame shows "Done, your rental agency has your Road Ready code" only after
roadready:receivedwith the same code. Without it for 5 seconds it shows the code with copy and email buttons, as the normal guide does, so the traveller is never left without their code. A latereceivedstill switches to "Done". roadready:completedcan arrive more than once for the same code, for example when the traveller reloads the page or after a latehello. Store it idempotently.- The frame posts only to your registered origins, never to
"*", and ignores messages from anyone but its parent page on a registered origin. - Check
event.originon your side as in the example, and send your messages with the Road Ready origin as the target (https://staging.roadreadynz.comon staging).
Good to knowclipboard, frame size, storage
- Keep
allow="clipboard-write"on the iframe. When your page does not confirm a code, the traveller sees it with a copy button. Browsers refuse the clipboard to a frame from another site unless the host page allows it; the guide then tries an older copy method, which some browsers also block in a frame, and the traveller has to select the code by hand. - Give the frame at least 360 × 640 pixels. The video plays inline inside the frame, as in the normal guide; there is no full-screen mode.
- Browsers keep a framed page's storage separate per site. A traveller who took the guide on roadreadynz.com, or in another site's frame, starts again in yours and gets a new code, which verifies like any other.
langtakes one of the guide languages in any case, or a bare or regional form such asen,en-GB,jaorzh-Hant(Traditional Chinese); one the guide does not have shows English.- The frame is not indexed by search engines and is never cached.
Completion webhook
We tell your system the moment a traveller has a Road Ready code, so the booking is marked without anyone typing the code.
Who can have one:
- A booking platform, for all its agencies: one webhook per platform, with the platform key for the calls below.
- An agency with its own booking system and no booking platform: one webhook for that agency, with the agency's own key. An agency on a booking platform has none of its own; its events go to the platform's webhook, or nowhere if the platform has none.
Every event goes to one receiver only. The request, the signature and the rules below are the same for both.
It is sent only when the traveller opened the guide through a link that carried your booking
reference as ref. A traveller who scans the QR poster or opens the short link has no ref, so
nothing is sent for them: verify their code as before. The link may be the guide link or the
agency's shortLink with ref added, and both take lang to choose the guide's language:
https://roadreadynz.com/guide?c=<code>&ref=<your reference>
https://roadreadynz.com/guide?c=<code>&lang=<language>&ref=<your reference>https://roadreadynz.com/?c=<code>&ref=<your reference>Handling an event
- Check the signature Before anything else, over the raw bytes exactly as they arrived (not over a body you parsed and serialised again), and compare in constant time. See Checking the signature.
- Match the booking Look the booking up by
refand check thatcompanyIdis the agency of that booking's operator. Anyone can changecin a link, so an event can carry another agency's code with your reference. - Verify the code Verify
codefor that agency with Verify a Road Ready code. The code is valid the moment the webhook arrives. This also makes a replayed request harmless. - Answer 2xx Also for a
refyou do not know, and for aneventIdyou have already handled (store the ones you have), or we keep sending it.
Staff can always verify a code by hand; the webhook does not replace that.
The request
your URLFrom Road Ready{
"code": "ABC234",
"companyId": "JMY",
"ref": "0b9f6c1e-7a52-4d0e-9c53-2f1f6f0a8f11",
"completedAt": "2026-10-13T01:02:03.000Z",
"eventId": "evt_0123456789abcdef0123456789abcdef"
}Content-Type: application/json
X-RoadReady-Signature: sha256=<HMAC-SHA256 of the raw body, lower-case hex>
X-RoadReady-Event-Id: evt_0123456789abcdef0123456789abcdef
X-RoadReady-Timestamp: 2026-10-13T01:02:05.000Z
User-Agent: RoadReadyNZ-Webhook/1| Field | Notes |
|---|---|
code |
The traveller's Road Ready code. |
companyId |
The agency code the guide was opened for (the c of the link). |
ref |
Your reference from the link. Always present. |
completedAt |
When the traveller got the code or, with a code they already held, opened your link. ISO 8601 in UTC. |
eventId |
Identifies this event. The same on every repeat of it. |
| Header | Notes |
|---|---|
X-RoadReady-Signature |
sha256= and the HMAC-SHA256 of the raw request body with your signing secret, in lower-case hex. |
X-RoadReady-Event-Id |
The body's eventId. |
X-RoadReady-Timestamp |
When this attempt was sent, ISO 8601 in UTC. For your logs; it is not part of the signature. |
User-Agent |
RoadReadyNZ-Webhook/1 |
Ignore fields you do not know: we may add some.
Checking the signature
import { createHmac, timingSafeEqual } from "node:crypto";
function isFromRoadReady(rawBody /* Buffer */, signatureHeader, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const given = Buffer.from(signatureHeader ?? "");
return given.length === expected.length && timingSafeEqual(given, Buffer.from(expected));
}Test vectorcheck your code against a known signature
The body
{"code":"ABC234","companyId":"JMY","ref":"0b9f6c1e-7a52-4d0e-9c53-2f1f6f0a8f11","completedAt":"2026-10-13T01:02:03.000Z","eventId":"evt_0123456789abcdef0123456789abcdef"}
with the secret test-secret-not-a-real-one gives
sha256=095c735e8adb4170be60b62dbf2c5c1b01af2bd7c869874870989b8c8a676d9a.
Your answer and retries
Any 2xx means you have it; we do not read the response body. Anything else is a failed attempt and
is sent again: 403, 503, every other 4xx and 5xx, a redirect (we do not follow redirects), no
answer within 10 seconds, or a connection that fails. After a failed attempt we try again:
- 1 min after
- 5 min
- 15 min
- 1 h
- every hour
- 72 h stop
After 72 hours we stop, unless we send it again by hand after a problem on either side is fixed.
Every repeat has the same eventId and the same body. The same booking may also be reported by two
events with different codes, for example when two drivers of one booking each complete the guide.
When your endpoint keeps failingthe pause
If your endpoint fails every attempt for 30 minutes or more (at least 5 in a row), we pause: we send
one waiting event about once an hour instead of each one on its own schedule, and once you answer it
with 2xx the rest follow within minutes, a few at a time. A pause loses nothing, and the 72 hours
still apply; an event still waiting at its 72 hour mark is given up with
lastError: receiver_paused.
When events are sent
One event is sent for each ref, whichever way the traveller comes to hold a code for that agency:
- they pass the quiz. The browser remembers the
refof the link for 30 days, so a traveller who opens it today and finishes the guide tomorrow in the same browser is still reported. The guide removesreffrom the address once it has it, so do not expect to see it there; - they already hold a valid code from another agency's guide, which then counts for this agency as well: the event is sent when they open your link;
- they already hold a valid code for this agency (a second booking with the same operator, or the
email opened again): the event is sent when they open your link, with the code they hold. A
refwe have reported is not reported again.
Daily cap per traveller10 refs per traveller and agency in 24 hours
For one traveller and agency we report at most 10 different refs in 24 hours; a link beyond that
opens the guide as usual, and nothing is sent for it. This limits one traveller (one browser), not
the number of events for an agency: anyone who has a link can complete the guide, so your endpoint
must not rely on it to bound what arrives. Check each event as described above, and tell us if you
see events you cannot explain: we can switch your webhook off at once.
Test your endpoint
/partner/webhook/testPlatform keyOnce your webhook is set up, sends a test event to it, whenever you like, on staging and on production.
Request body
| Field | Notes |
|---|---|
agencyCode optional |
One of your active agencies. It becomes the event's companyId, so your "is this agency mine" check passes as for a real event. Without it we take your newest active agency (the first active one in the order of GET /partner/agencies). |
curl -X POST "https://roadreadynz.com/api/partner/webhook/test" \
-H "Authorization: ApiKey $ROAD_READY_KEY" \
-H "Content-Type: application/json" \
-d '{ "agencyCode": "JMY" }'{ "eventId": "evt_5d41402abc4b2a76b9719d911017c592" }We then send the event like any other: the same body, signature, retries and time-out. Only these tell it apart:
| Test event | |
|---|---|
ref |
Starts with roadready-test-, then random characters. No guide link can carry this prefix, so a real event never has it. |
code |
Always TEST00. It is never issued to a traveller and never verifies (404). |
X-RoadReady-Test |
Header 1, sent only with a test event. |
Every test is a new event with its own eventId. While deliveries to you are paused (see
Your answer and retries), a test event waits like any other: it shows
pending with attempts: 0 until it is the hour's probe or the pause ends, so a fixed endpoint is
found within the hour.
Errors
| Status | code |
When |
|---|---|---|
| 400 | invalid_request |
The body is not a JSON object with only agencyCode, whichever key. |
| 401 | invalid_api_key |
An agency key without agencyCode, or with another agency's code. |
| 409 | webhook_not_configured |
No webhook is set up for you in that environment, also for an agency whose events go to its booking platform. Ask us. |
| 409 | webhook_disabled |
Your webhook is switched off. Ask us. |
| 429 | test_limit_exceeded |
5 test events per receiver (platform or agency) and clock hour (UTC) already; the sixth waits for Retry-After, the seconds until the next hour. |
Both webhook calls also count against your rate limits. Errors are answered as
{ "error": "...", "code": "..." }: handle code; the wording of error can change.
Follow a delivery
/partner/webhook/events/{eventId}Platform keyShows how the delivery of any of your events is going, a test or a real one.
curl "https://roadreadynz.com/api/partner/webhook/events/evt_5d41402abc4b2a76b9719d911017c592" \
-H "Authorization: ApiKey $ROAD_READY_KEY"{
"eventId": "evt_5d41402abc4b2a76b9719d911017c592",
"test": true,
"status": "pending",
"attempts": 1,
"lastStatus": 503,
"lastError": null,
"createdAt": "2026-10-14T01:00:00.000Z",
"lastAttemptAt": "2026-10-14T01:00:01.000Z",
"nextAttemptAt": "2026-10-14T01:01:01.000Z",
"deliveredAt": null
}| Field | Notes |
|---|---|
status |
pending (being sent, or waiting for the next attempt), delivered (you answered 2xx) or dead (given up after 72 hours). |
attempts |
Attempts made so far. |
lastStatus |
The HTTP status your endpoint answered last, or null. |
lastError |
Why the last attempt got no status, or null: timeout (no answer within 10 seconds), network (no connection), url_refused (your URL is not acceptable), webhook_off (your webhook is switched off), receiver_paused (given up after 72 hours while deliveries to you were paused), or a fault on our side (no_secret, secret_unavailable, lookup_failed), which we see as well. |
nextAttemptAt |
When the next attempt is due; null once delivered or dead. |
The answer never contains code or ref. An event that is not yours answers 404
event_not_found, the same as an unknown id.
Reference
Error codes
Every call except verify answers errors as
{ "error": "Human-readable message", "code": "machine_readable_code" }Handle code; the wording of error can change. Verify keeps its own error bodies; see
Verify a Road Ready code.
code |
Status | What to do |
|---|---|---|
invalid_request |
400 | Fix the request. error names the field. |
logo_invalid |
400 | The logo URL or file is not acceptable: not https, not a public host, larger than 5 MB, not a PNG, JPEG, WebP or GIF, or an image that cannot be read (for example a truncated file). No agency was created; on a change, nothing was changed. |
logo_fetch_failed |
400 | We could not download the logo within 8 seconds, or it redirected somewhere we do not follow. No agency was created; on a change, nothing was changed. Retry, or create without logoUrl. |
invalid_api_key |
401 | Key missing, wrong, revoked or expired, or a key of the other environment. |
partner_suspended |
403 | Contact us. |
agency_not_found |
404 | No agency of yours has this code. |
event_not_found |
404 | Webhook event only: no event of yours has this id. |
agency_inactive |
409 | The agency is inactive and cannot be changed. See Inactive agencies; contact us to reactivate it. |
contact_email_not_changeable |
409 | Change only: the agency signed up with us itself, and its contact email is its dashboard login. Send the request without contactEmail. |
webhook_not_configured |
409 | Webhook test only: no webhook is set up for you in this environment, or (agency key) your agency's events go to its booking platform. Ask us. |
webhook_disabled |
409 | Webhook test only: your webhook is switched off. Ask us. |
booking_code_expired |
410 | Verify only: the Road Ready code was issued more than 365 days ago. The traveller takes the quiz again. |
quota_exceeded |
429 | More agencies created in the last 24 hours than your daily limit (50 unless agreed otherwise). Repeated creates of existing agencies still succeed. |
test_limit_exceeded |
429 | Webhook test only: 5 test events this hour already. Wait for Retry-After. |
rate_limited |
429 | Too many calls this minute. Wait for the Retry-After header; see Rate limits. |
internal_error |
500 | Something failed on our side, also while checking your key (your key is not the problem). Retry; a create retried with the same externalId cannot make a duplicate. |
Inactive agencies
When an operator stops working with Road Ready, its agency is marked inactive; tell us if one of your operators leaves. An inactive agency:
- is still returned by read and list, with
"active": false; - cannot be changed: create (repeated with its
externalId), change and poster retry return409withagency_inactive, and change nothing; - keeps its
codeandexternalId: neither is given to another agency; - still verifies the Road Ready codes issued for it. Travellers who open its link still get the guide and a Road Ready code, but that code is not issued for the agency and does not verify for it.
We can make it active again on request; active is then true and everything works as before.
Setting an agency to inactive through the API is not available yet.
Logo requirements
httpson the default port, on a publicly reachable host. Redirects are followed up to three times and must meet the same rules.- PNG, JPEG, WebP or GIF, checked by file content (not by the file name or
Content-Type), 5 MB at most. - A GIF is kept as a still image: its first frame, stored as PNG. An animated GIF does not animate on
the poster. A GIF may have 8 megapixels at most (for example 2800 x 2800); a larger one, or the
rare one whose PNG is larger than 5 MB, is answered with
logo_invalid. - The URL is fetched once, during create or change. Later changes to the file are not picked up;
send the URL again with
PATCH /partner/agencies/{code}to use a new version.
Questions, need a limit raised, or something the API does not do yet? Contact us.