Authentication
All API requests require an API key. Include your key in the
X-API-Key
header or as an
Authorization: Bearer
token.
curl https://grailguard.io/api/v1/quote \ -H "X-API-Key: gg_abc123def456-78901234abcdef5678"
API keys follow the format
keyId-keySecret
. The key ID is the first segment (before the hyphen); the secret is everything after. Keep your full key confidential.
Rate Limits
Each API key has a per-minute rate limit (default: 60 requests/min). When exceeded, the API returns
429 Too Many Requests
. The response includes a
Retry-After
header with the number of seconds to wait.
A second, per-IP limit also applies to everything under
/api/
: 600 requests per 15 minutes (a sustained ~40/min). It is the stricter of the two for a single-IP integration, and it returns a different body —
{"error": "Too many requests, please try again later."}
— with no
Retry-After
. Pace to the per-IP limit, or spread traffic across egress IPs.
{
"error": "Rate limit exceeded. Try again in 42s"
}
Error Handling
The API uses standard HTTP status codes. All error responses include a JSON body with an
error
field.
| Code | Meaning |
|---|---|
400
|
Bad Request — missing or invalid parameters |
401
|
Unauthorized — missing or invalid API key |
403
|
Forbidden — key lacks the required scope, or the key has been disabled |
404
|
Not Found — resource does not exist |
402
|
Payment Required — the supplied payment reference does not match the server-calculated total |
409
|
Conflict — that payment reference is already attached to another booking |
429
|
Rate limit exceeded |
451
|
Unavailable For Legal Reasons — the booking failed sanctions screening |
503
|
Service Unavailable — screening or payment verification is temporarily unreachable; retry |
500
|
Internal server error |
Endpoints
Get a shipping quote based on origin, destination, and item value. Quotes are valid for 24 hours.
Query Parameters
| Param | Type | Description |
|---|---|---|
pickup_zip
|
string |
Origin ZIP / postal code. Optional — supply both ZIPs (or a
distance
in miles) so we can measure the route. Omit them all and the quote is priced at zero distance.
|
delivery_zip
|
string |
Destination ZIP / postal code. Optional, same as
pickup_zip
.
|
declared_value
|
number |
Item value in USD. No default — omit it and the quote comes back with
declaredValue: null
and no coverage surcharge. Pass either this or
value_bucket
, not both (mismatched pairs are rejected with 400).
|
service_tier
required
|
string |
metro
,
nationwide
,
international
,
elite
, or
psa-dropoff
. There is no default — a missing or unknown tier returns 400. The legacy alias
standard
is still accepted and maps to
metro
; prefer the canonical names. Note
metro
is capped at 200 route miles — longer routes return 400 with
suggestedTiers
.
|
Example
curl "https://grailguard.io/api/v1/quote?pickup_zip=10001&delivery_zip=90210&declared_value=100000&service_tier=nationwide" \ -H "X-API-Key: YOUR_API_KEY"
{
"quote": {
"total": 3324,
"currency": "USD",
"serviceTier": "nationwide",
"deliverySpeed": "standard",
"declaredValue": 100000,
"valueBucket": "100k-150k",
"estimatedTransit": "1-3 business days",
"validFor": "24 hours",
"distanceMiles": 2454,
"breakdown": {
"baseFee": 2999,
"rushFee": 0,
"flightCost": 0,
"coverageSurcharge": 325,
"coverageAmount": "tier-bucket",
"insuranceSurcharge": 325,
"insuranceCoverage": "tier-bucket",
"terminalDiscount": 0,
"airportPickupSurcharge": 0,
"airportDeliverySurcharge": 0,
"metroDistanceSurcharge": 0
},
"note": "Total matches grailguard.io instant quote. Bookings created via POST /api/v1/bookings will be charged this exact amount."
}
}
Two other shapes to handle: if the declared value is above the tier's self-serve limit you get HTTP 200 with quote.contactRequired: true, a note and supportEmail instead of a price — surface a “contact us” CTA rather than an error. If the tier cannot serve the route (e.g. metro over its 200-mile cap) you get HTTP 400 with error and suggestedTiers.
Types: coverageSurcharge / insuranceSurcharge are numbers (USD). coverageAmount / insuranceCoverage are strings, not amounts — one of "included", "tier-bucket" or "basic", describing which coverage band applies. Do not type them as numbers.
Field rename notice: coverageSurcharge and coverageAmount are the canonical field names going forward. The legacy insuranceSurcharge and insuranceCoverage names are returned alongside for back-compat and will be removed in a future major API version. GrailGuard's Coverage Policy is a contractual loss-coverage promise we make to customers, not insurance.
Create a new delivery booking. Returns a tracking number and tracking URL.
Request Body (JSON)
| Field | Type | Description |
|---|---|---|
customer_name
required
|
string | Full name of the sender |
customer_email
required
|
string | Email for delivery notifications |
customer_phone
|
string | Phone number |
pickup_address
required
|
string | Full pickup address |
delivery_address
required
|
string | Full delivery address |
item_description
|
string | Description of the item |
declared_value
|
number | Item value in USD for coverage |
service_tier
required
|
string |
Same enum as the quote endpoint:
metro
,
nationwide
,
international
,
elite
,
psa-dropoff
(legacy alias
standard
maps to
metro
). Required — a missing tier returns 400.
|
tos_accepted
required
|
boolean |
Must be
true
. You are asserting that your end customer accepted the GrailGuard Terms and Coverage Policy before you submitted this booking. Omitting it returns 400.
|
stripe_payment_id
|
string |
Payment processor transaction ID if pre-paid. Omit it and the booking is recorded with
paymentStatus: "pending"
and the server-calculated amount for invoicing.
|
Example
curl -X POST https://grailguard.io/api/v1/bookings \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_name": "Jane Doe",
"customer_email": "jane@example.com",
"pickup_address": "123 Main St, New York, NY 10001",
"delivery_address": "456 Oak Ave, Los Angeles, CA 90210",
"item_description": "Patek Philippe Nautilus 5711",
"declared_value": 125000,
"service_tier": "nationwide",
"tos_accepted": true
}'
{
"booking": {
"id": 42,
"trackingNumber": "GG-9C4E2A7B15D8F30E6B24",
"status": "confirmed",
"serviceTier": "nationwide",
"deliverySpeed": "standard",
"amountUsd": 3324,
"paymentStatus": "pending",
"trackingUrl": "https://grailguard.io/track.html?tn=GG-9C4E2A7B15D8F30E6B24",
"createdAt": "2026-04-15T14:30:00.000Z"
}
}
deliveryCode
— the one-time code your customer’s recipient gives the courier at handoff. Treat it like a password: do not log it, do not display it to anyone but the sender or recipient. It is not retrievable from any GET endpoint.
Retrieve full booking details including all tracking events.
Scope: you can only fetch bookings attributable to your
partner account — bookings that carry your referral code, or that
were created through your own API keys. A tracking number outside your
scope returns 404 with the same body as a tracking number
that does not exist; we do not distinguish the two, so this endpoint
cannot be used to confirm that a shipment exists. If your key is not
linked to a partner account yet, every lookup returns 404
— contact support@grailguard.io to have it linked.
courier is the name of the GrailGuard courier assigned to
your shipment, or null before assignment.
Example
curl https://grailguard.io/api/v1/bookings/GG-9C4E2A7B15D8F30E6B24 \ -H "X-API-Key: YOUR_API_KEY"
{
"booking": {
"trackingNumber": "GG-9C4E2A7B15D8F30E6B24",
"status": "in_transit",
"serviceTier": "nationwide",
"customer": { "name": "Jane Doe", "email": "jane@example.com" },
"pickup": "123 Main St, New York, NY 10001",
"delivery": "456 Oak Ave, Los Angeles, CA 90210",
"item": { "description": "Patek Philippe Nautilus 5711", "declaredValue": 125000 },
"courier": "Alex Rivera",
"payment": { "status": "paid", "amount": 3324 },
"scheduledDate": "2026-04-16",
"createdAt": "2026-04-15T14:30:00.000Z",
"updatedAt": "2026-04-15T18:00:00.000Z",
"trackingUrl": "https://grailguard.io/track.html?tn=GG-9C4E2A7B15D8F30E6B24"
},
"events": [
{ "status": "Booking Confirmed", "location": "New York, NY", "description": "Booking created", "timestamp": "2026-04-15T14:30:00.000Z" },
{ "status": "Courier Assigned", "location": "New York, NY", "description": "Courier Alex Rivera assigned", "timestamp": "2026-04-15T15:00:00.000Z" },
{ "status": "Picked Up", "location": "123 Main St, New York, NY", "description": "Item collected", "timestamp": "2026-04-16T09:00:00.000Z" }
]
}
List the bookings attributable to your partner account, with pagination and optional status filtering.
Scope: this returns bookings that carry your referral
code (from ?ref= links, the embed widget, or a
partner-linked promo code), plus any bookings your own API keys
created. It does not return other partners' bookings, and it does not
return bookings that have no partner attribution. An empty list is a
valid answer. If your key is not linked to a partner account yet the
list is always empty — contact support@grailguard.io to have it
linked.
Query Parameters
| Param | Type | Description |
|---|---|---|
page
|
int | Page number (default: 1) |
limit
|
int | Results per page, max 100 (default: 25) |
status
|
string |
Filter by status. The full pipeline is
pending
,
confirmed
,
courier_assigned
,
picked_up
,
in_transit
,
out_for_delivery
,
delivered
,
cancelled
,
recipient_not_home
.
|
Example
curl "https://grailguard.io/api/v1/bookings?page=1&limit=10&status=confirmed" \ -H "X-API-Key: YOUR_API_KEY"
{
"bookings": [
{
"trackingNumber": "GG-9C4E2A7B15D8F30E6B24",
"customer": "Jane Doe",
"status": "confirmed",
"tier": "nationwide",
"amount": 3324,
"createdAt": "2026-04-15T14:30:00.000Z",
"updatedAt": "2026-04-15T14:30:00.000Z"
}
],
"total": 1,
"page": 1,
"pages": 1
}
Webhooks
Subscribe to real-time events so your system is notified when a booking is created, delivered or cancelled.
Register a new webhook subscription. Returns a signing secret for payload verification.
Request Body (JSON)
| Field | Type | Description |
|---|---|---|
url
required
|
string | HTTPS endpoint to receive events |
events
|
string | Comma-separated event types. Pick from the list below. |
booking.created
,
booking.delivered
,
booking.cancelled
. These are the three GrailGuard actually emits. A previous version of this page also listed
booking.status_changed
— nothing emits it, so do not build against it. Poll
GET /api/v1/bookings/:trackingNumber
for intermediate status.
Example
curl -X POST https://grailguard.io/api/v1/webhooks \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-app.com/webhooks/grailguard", "events": "booking.created,booking.delivered" }'
{
"webhook": {
"id": 7,
"url": "https://your-app.com/webhooks/grailguard",
"events": ["booking.created", "booking.delivered"],
"secret": "whsec_a1b2c3d4e5f6...",
"note": "Save this secret - we use it to sign webhook payloads via HMAC-SHA256 in the X-GrailGuard-Signature header."
}
}
List all active webhook subscriptions for your API key.
Remove a webhook subscription by ID.
Scopes & Permissions
API keys are assigned scopes that control which endpoints they can access. Scopes are cumulative — a key with
admin
scope also has
write
and
read
access.
| Scope | Endpoints | Use Case |
|---|---|---|
read
|
GET /quote, GET /bookings, GET /bookings/:tn | Tracking integrations, dashboards |
write
|
POST /bookings + all read endpoints | Booking creation (e-commerce, platforms) |
admin
|
All endpoints including webhooks | Full integration partners |
Verifying Webhook Signatures
Every webhook POST includes an
X-GrailGuard-Signature
header of the form
t=<unix-seconds>,v1=<hex>
.
v1
is an HMAC-SHA256 over the string
<t>.<raw request body>
(timestamp, a literal dot, then the exact bytes we sent), keyed with your subscription secret. Signing the body alone will not match. Reject anything where
t
is more than 300 seconds from your clock — that is the replay window we enforce on our side too. Each POST also carries
X-GrailGuard-Event
and a unique
X-GrailGuard-Delivery
id; use the delivery id to make your handler idempotent, because failed deliveries are retried.
const crypto = require('crypto');
// header looks like: t=1776211200,v1=9f86d0818...
function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
if (typeof header !== 'string') return false;
const map = {};
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i > 0) map[part.slice(0, i).trim()] = part.slice(i + 1).trim();
}
const t = Number(map.t);
const v1 = map.v1;
if (!Number.isFinite(t) || !v1) return false;
// Replay guard — reject anything outside the tolerance window.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > toleranceSeconds) return false;
// The signed string is `${t}.${rawBody}` — NOT the body alone.
const expected = crypto
.createHmac('sha256', secret)
.update(t + '.' + rawBody)
.digest('hex');
if (expected.length !== v1.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(v1, 'hex')
);
}
// Express handler — express.raw() matters: sign the EXACT bytes we sent.
app.post('/webhooks/grailguard', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
const sig = req.headers['x-grailguard-signature'];
if (!verifyWebhook(raw, sig, process.env.GG_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(raw);
// event.id is unique per delivery attempt-set — dedupe on it.
console.log('Event:', event.id, event.type, event.data);
res.sendStatus(200);
});
crypto.timingSafeEqual
to prevent timing attacks.
Webhook Payload Format
{
"id": "0f3a9c62-4d18-4b7e-9a2f-1c8d5e6b7a90",
"type": "booking.delivered",
"created": 1776211200,
"data": {
"trackingNumber": "GG-9C4E2A7B15D8F30E6B24",
"id": 42,
"status": "delivered",
"signedBy": "J. Rivera",
"deliveredAt": "2026-04-15T18:00:00.000Z"
}
}
The envelope is always { id, type, created, data } — created is unix seconds, not an ISO string. The data object differs per event: booking.created carries trackingNumber, id, status, serviceTier, pickupCity/pickupState, deliveryCity/deliveryState, amountPaid, createdAt; booking.delivered carries the fields above. Street addresses and customer contact details are deliberately never sent in a webhook.