API documentation
REST API for hire-yard operations.
Cognito JWT auth, resource-oriented endpoints, JSON in / JSON out (plus CSV for the tax export). API access ships with the Pro tier ($379 AUD / month). Sandbox available for development against your trial tenant — no separate environment to set up.
Quickstart
- 1. Sign in. The API uses the same Cognito user pool as the web app. Sign in via the /counter/ flow and pull the JWT from your session, or use the AWS Cognito SDK directly with
InitiateAuth. - 2. Pick the right base URL. Tenants are region-pinned at signup based on the email TLD (.com.au → ap-southeast-2, .co.nz → ap-southeast-6, .co.uk → eu-west-2, etc.). Use the base URL for your region — cross-region requests work but add latency.
- 3. Send the JWT in the Authorization header.
Authorization: Bearer <id_token>. The token's custom claims (tenantId, role, homeRegion) gate access to every endpoint automatically.
# Example: list your assets (curl)
curl https://api.yardguild.com/assets \
-H "Authorization: Bearer $YG_TOKEN"
# Example: seed your starter library
curl -X POST https://api.yardguild.com/assets/seed \
-H "Authorization: Bearer $YG_TOKEN" \
-H "Content-Type: application/json" \
-d '{"vertical":"tools","country":"AU"}'
# Example: create a contract
curl -X POST https://api.yardguild.com/contracts \
-H "Authorization: Bearer $YG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"renter": {"name":"Acme Builders", "email":"j@acme.com.au"},
"items": [{"asset_id":"abc-123","daily_rate":70}],
"due_back_at": "2026-06-02T17:00:00+10:00"
}'Base URL
All API requests use a single global endpoint:
https://api.yardguild.com
Authenticate every request with your JWT (Authorization: Bearer …).
Conventions
- JSON in, JSON out. Send
Content-Type: application/jsonon POST / PUT / PATCH. Errors return{ "code": "...", "message": "..." }. Exceptions: CSV endpoints returntext/csv, presign endpoints return the upload URL. - Tenant isolation by construction. Every endpoint scopes by the
custom:tenantIdclaim. You cannot read another tenant's data — even a misformed query falls back to your tenant. - Errors are predictable.
401missing/invalid JWT ·403role insufficient ·404resource not found ·409conflict (e.g. illegal state transition) ·422validation error (per-field details inerrors[]) ·429rate-limited ·5xxserver error (retry with backoff). - Rate limit: 50 req/sec per tenant. Burst of 100. Hits return
429withRetry-Afterheader. Email us if you need more — usually we'll just raise it. - Idempotency. POST endpoints accept an optional
Idempotency-Keyheader; we deduplicate for 24 hours. The PayPal webhook ingestion is idempotent by design (event ID stored in DDB for 90-day TTL). - No payment processing on renters. The Bonds endpoints record payment details (method / reference / ID-sighted / photos) but never charge anything. The money flow stays with your existing processor — cash, EFTPOS, Square, your own PayPal Business or Stripe, etc. The only place YardGuild processes a payment is your monthly Pro-tier subscription via
/billing/subscribe.
Endpoints
All endpoints require a Cognito JWT in the Authorization header unless otherwise noted. The handful of public endpoints (waiver renter flow, PayPal webhook, health) carry their own auth mechanism (token, signature, or none).
Tenant
Read or update the tenant profile (yard name, country, ABN/NZBN, alert email, GST flag, custom claims). The tenant is derived from the JWT — you can only access your own.
- GET
/tenant/meFetch the authenticated tenant's profile.
- PUT
/tenant/meUpdate tenant profile. Body: partial TenantMeta (name, country, timezone, abn_or_nzbn, alert_email, gst_registered).
Assets
Inventory items. Each asset has a name, category, optional size code (e.g. SKI_BOOT_MP28_5), daily rate, default bond, condition state, and photo URL. CSV import/export is supported for bulk operations.
- GET
/assetsList all assets for the tenant. Returns paginated array.
- POST
/assetsCreate a single asset. Body: Asset (without asset_id; the server assigns).
- POST
/assets/importBulk CSV import. Body: multipart/form-data with file= field. Returns { created, failed }.
- GET
/assets/exportBulk CSV export. Returns text/csv with all tenant assets.
- POST
/assets/seedSeed starter library. Body: { vertical: "tools"|"party"|"ski", country: "AU"|"NZ"|"UK"|"IE"|"CA"|"US" }. Returns { templates, assets } counts.
- GET
/assets/templatesList available starter templates for the tenant's vertical + country (without creating them).
Contracts
Rental contracts move through states: RESERVED → CHECKED_OUT → RETURNED → SETTLED. Each contract has line items (assets), a renter (name + contact), and optional bond + waiver records.
- GET
/contractsList contracts. Query params: status, since, limit.
- POST
/contractsCreate a contract. Body: { renter, items[], due_back_at }.
- POST
/contracts/{id}/{action}Transition the contract. action ∈ { check-out, check-in, settle, cancel }. Body validates state-transition.
- POST
/contracts/{id}/photo/presignGet a presigned S3 PUT URL for uploading a condition photo. Body: { filename, content_type }. Returns { upload_url, photo_url, expires_at }.
Bonds (manual tracking — we don't process renter payments)
Bonds are recorded on the contract with payment method, reference, ID sighted, and evidence photos. The money flow stays with whichever processor you used (cash / EFTPOS / Square / your own PayPal / Stripe / Moneris). YardGuild only keeps the records.
- POST
/contracts/{id}/bondRecord a new bond. Body: { amount, currency, payment_method, reference_number, id_sighted_type, id_sighted_number, evidence_photos[] }.
- GET
/contracts/{id}/bondList all bond records on the contract.
- POST
/contracts/{id}/bond/{depositId}/captureMark a bond captured (damage). Body: { amount?, reason }. Set amount < bond_amount for partial-capture.
- POST
/contracts/{id}/bond/{depositId}/voidMark a bond released (clean return). Body: { reason? }.
Waivers
Waivers use a magic-link flow: staff starts the waiver (protected), the renter receives a token URL, the renter views (public) and signs (public, token-auth). Signed PDFs are stored in S3 with PII redacted from logs.
- POST
/waiver/startStaff: start a waiver for a renter. Body: { contract_id, renter_email, renter_phone }. Returns { token, magic_url, expires_at }.
- GET
/waiver/{token}Token-authRenter: fetch waiver content for signing. Public — token-authed.
- POST
/waiver/{token}/signToken-authRenter: submit signature + identity. Public — token-authed. Body: { name, signature_base64, ip_seen }.
Reports
Read-only aggregations over the tenant's data. All return JSON; the GST/BAS export returns CSV in your country's accounting format.
- GET
/reports/todayToday's overdue + due-back + cash-flow snapshot.
- GET
/reports/cash-flowCash flow per day for the last 30 days.
- GET
/reports/asset-utilizationUtilization % per asset for the last 30 days.
- GET
/reports/revenue-per-assetRevenue per asset for the last 30 days.
- GET
/reports/top-rentersTop 20 renters by contract count + revenue.
- GET
/reports/damage-rateDamage rate per category over the last 90 days.
- GET
/reports/seasonalSeasonal demand trend per category.
- GET
/reports/waiver-complianceAudit: every rental cross-referenced with a signed waiver record.
- GET
/reports/gst-export.csvTax-CSV export. Query: since=YYYY-MM-DD, until=YYYY-MM-DD. Format adapts to tenant country (ATO BAS / IRD / HMRC VAT / etc.). Returns text/csv.
Billing (your YardGuild subscription)
Manage your monthly subscription to YardGuild. This is the only place PayPal touches our system — renter payments are out-of-band (see Bonds).
- POST
/billing/subscribeStart a PayPal subscription for a tier. Body: { tier: "solo"|"counter"|"multi_yard"|"pro" }. Returns { approval_url } — redirect to it to complete.
- GET
/billing/statusCurrent subscription status. Returns { subscription_tier, subscription_status, current_period_end, approval_url }.
Health
Liveness check — useful for smoke-testing a deploy or monitoring uptime.
- GET
/healthPublicReturns { status: "ok", region, deployed_at }. Public, no auth.
Official SDK?
Not yet. The API is stable and curl/fetch is enough for everything on this page. We'll ship a TypeScript SDK once we have 5+ paying Pro tenants asking for it — until then, the TS types in our web client (web/src/lib/api.ts in the repo) are the closest thing to a typed reference. Copy them into your own project if it helps.
Need an integration we don't have today (Xero, MYOB, QuickBooks, Zapier)? Email hello@yardguild.com — we ship integrations based on paying-tenant demand.
Webhooks
We receive PayPal subscription webhooks at /webhooks/paypal (signature-verified, idempotent — see Bonds note about event-ID-keyed DDB rows with 90-day TTL).
Outbound webhooks
Register a URL at /counter/settings; we POST signed JSON to it when subscribed events fire. All four catalog events are live: contract.created, contract.returned, waiver.signed, and bond.captured. Email hello@yardguild.com if you'd like another event type and we'll prioritise based on demand.
Signature format — same shape as Stripe so existing receiver code mostly drops in. Header X-YardGuild-Signature: t=<unix-seconds>,v1=<hex>. Compute HMAC-SHA256 of `${t}.${raw-body}` with the signing secret we showed you on create, compare with constant-time equality, reject timestamps older than 5 minutes.
Reliability — asynchronous dispatch via DDB Streams so a slow receiver never blocks your /counter/* workflow. Failed deliveries retry on a 30s / 2min / 10min exponential backoff (3 retries total). After 5 consecutive failures we auto-disable the webhook — flip it back on in /counter/settings once your receiver is healthy. Every attempt shows up in the "Recent deliveries" panel for 30 days.
Idempotency — each delivery carries X-YardGuild-Event-Id (also in the body). Retries reuse the original event ID — dedupe on your side using that.
Verifying a delivery
Verify the signature against the raw request body — any re-serialisation (sorted keys, added whitespace) will fail the HMAC compare. Use a constant-time equality (hmac.compare_digest in Python, crypto.timingSafeEqual in Node) and reject anything signed more than 5 minutes ago.
Python (Flask)
import hashlib
import hmac
import time
from flask import Flask, request, abort
SIGNING_SECRET = "whsec_..." # from /counter/settings (shown once on create)
TOLERANCE_SECONDS = 300 # reject older than 5 minutes
app = Flask(__name__)
def parse_sig(header):
fields = {}
for piece in header.split(","):
k, _, v = piece.strip().partition("=")
fields[k] = v
return fields
@app.post("/yardguild-webhook")
def receive():
raw = request.get_data(as_text=True) # NOT request.json
sig = parse_sig(request.headers.get("X-YardGuild-Signature", ""))
try:
ts = int(sig["t"])
v1 = sig["v1"]
except (KeyError, ValueError):
abort(400)
if abs(time.time() - ts) > TOLERANCE_SECONDS:
abort(400)
expected = hmac.new(
SIGNING_SECRET.encode(),
f"{ts}.{raw}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401)
event = request.json
# idempotency: dedupe on event["event_id"] in your own table
return "", 204Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const SIGNING_SECRET = process.env.YARDGUILD_SIGNING_SECRET!;
const TOLERANCE_SECONDS = 300;
const app = express();
// IMPORTANT: capture the raw body. express.json() drops it; bodyParser.raw
// or this verify-hook gives us back the unparsed bytes.
app.use(
express.json({
verify: (req, _res, buf) => {
(req as any).rawBody = buf.toString("utf8");
},
}),
);
function parseSig(header: string) {
return Object.fromEntries(
header.split(",").map((p) => p.trim().split("=").map((s) => s.trim()) as [string, string]),
);
}
app.post("/yardguild-webhook", (req, res) => {
const { t, v1 } = parseSig(String(req.header("x-yardguild-signature") || ""));
const ts = parseInt(t, 10);
if (!ts || !v1) return res.sendStatus(400);
if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(`${ts}.${(req as any).rawBody}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
return res.sendStatus(401);
}
// idempotency: dedupe on req.body.event_id
res.sendStatus(204);
});Test your receiver without creating a real contract — each webhook row in /counter/settings has a Test fire button that posts a synthetic yardguild.test event to your URL signed with the real secret.
Need API access?
Pro tier ($379 AUD / month) includes API access, custom waiver templates, and priority chat. 30-day free trial — the API works against your trial tenant from day one.