REST API v1

GrailGuard Developer API

Integrate white-glove courier delivery into your platform. Get shipping quotes, create bookings, track packages, and receive real-time webhooks.

Authentication

All API requests require an API key. Include your key in the X-API-Key header or as an Authorization: Bearer token.

CURL
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.

Getting an API key: Contact your GrailGuard account manager or request one at grailguard.io/contact . Enterprise clients can generate keys via the admin panel.

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.

RESPONSE
{
  "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
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"
RESPONSE
{
  "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
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
  }'
RESPONSE 201
{
  "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"
  }
}
Handle with care: the 201 body also returns 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
curl https://grailguard.io/api/v1/bookings/GG-9C4E2A7B15D8F30E6B24 \
  -H "X-API-Key: YOUR_API_KEY"
RESPONSE
{
  "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
curl "https://grailguard.io/api/v1/bookings?page=1&limit=10&status=confirmed" \
  -H "X-API-Key: YOUR_API_KEY"
RESPONSE
{
  "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.

Before you build against this: live webhook delivery is currently provisioned by GrailGuard, not self-serve. A subscription created through the endpoints below is recorded against your API key but is not connected to the delivery pipeline, so it will not receive events. Email support@grailguard.io with your endpoint URL and the events you want and we will register it on the live dispatcher and send you the signing secret. Everything below — signature format, payload shape, retry behaviour — describes what that live dispatcher sends.

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.
Available events: 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
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" }'
RESPONSE 201
{
  "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.

NODE.JS
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);
});
Security: Always verify signatures before processing webhook events. Reject any request where the signature does not match. Use crypto.timingSafeEqual to prevent timing attacks.

Webhook Payload Format

JSON
{
  "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.