Yard Guild

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

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/me

    Fetch the authenticated tenant's profile.

  • PUT/tenant/me

    Update 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/assets

    List all assets for the tenant. Returns paginated array.

  • POST/assets

    Create a single asset. Body: Asset (without asset_id; the server assigns).

  • POST/assets/import

    Bulk CSV import. Body: multipart/form-data with file= field. Returns { created, failed }.

  • GET/assets/export

    Bulk CSV export. Returns text/csv with all tenant assets.

  • POST/assets/seed

    Seed starter library. Body: { vertical: "tools"|"party"|"ski", country: "AU"|"NZ"|"UK"|"IE"|"CA"|"US" }. Returns { templates, assets } counts.

  • GET/assets/templates

    List 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/contracts

    List contracts. Query params: status, since, limit.

  • POST/contracts

    Create 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/presign

    Get 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}/bond

    Record a new bond. Body: { amount, currency, payment_method, reference_number, id_sighted_type, id_sighted_number, evidence_photos[] }.

  • GET/contracts/{id}/bond

    List all bond records on the contract.

  • POST/contracts/{id}/bond/{depositId}/capture

    Mark a bond captured (damage). Body: { amount?, reason }. Set amount < bond_amount for partial-capture.

  • POST/contracts/{id}/bond/{depositId}/void

    Mark 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/start

    Staff: start a waiver for a renter. Body: { contract_id, renter_email, renter_phone }. Returns { token, magic_url, expires_at }.

  • GET/waiver/{token}Token-auth

    Renter: fetch waiver content for signing. Public — token-authed.

  • POST/waiver/{token}/signToken-auth

    Renter: 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/today

    Today's overdue + due-back + cash-flow snapshot.

  • GET/reports/cash-flow

    Cash flow per day for the last 30 days.

  • GET/reports/asset-utilization

    Utilization % per asset for the last 30 days.

  • GET/reports/revenue-per-asset

    Revenue per asset for the last 30 days.

  • GET/reports/top-renters

    Top 20 renters by contract count + revenue.

  • GET/reports/damage-rate

    Damage rate per category over the last 90 days.

  • GET/reports/seasonal

    Seasonal demand trend per category.

  • GET/reports/waiver-compliance

    Audit: every rental cross-referenced with a signed waiver record.

  • GET/reports/gst-export.csv

    Tax-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/subscribe

    Start a PayPal subscription for a tier. Body: { tier: "solo"|"counter"|"multi_yard"|"pro" }. Returns { approval_url } — redirect to it to complete.

  • GET/billing/status

    Current 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/healthPublic

    Returns { 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 formatsame 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.

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

Idempotencyeach 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 "", 204

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