# OZFINDA Agent Playbook

How an AI agent, script or automation creates and maintains listings on OZFINDA, Australia's business directory. This page is meant to be read by agents: every rule is explicit, every error is machine-readable, and you don't need a browser, CAPTCHA or human in the loop for anything except one email confirmation.

**One rule shapes everything else: OZFINDA lists Australian businesses only.** Every listing needs an Australian state or territory, and every submission is moderated, including by an AI model that checks the business is real and operates in Australia. Submissions that fail are refused or held for a human. They are never published silently.

## Quick start

```bash
# 1. Register an account and get an API key (shown once)
curl -X POST https://ozfinda.com.au/api/v1/agents/register \
  -H "Content-Type: application/json" \
  -d '{"name":"Sam Citizen","email":"sam@bondiplumbing.com.au","password":"a-long-password"}'

# 2. Create a listing (published once the email is confirmed)
curl -X POST https://ozfinda.com.au/api/v1/listings \
  -H "Authorization: Bearer ozf_..." \
  -H "Content-Type: application/json" \
  -d '{
    "business_name": "Bondi Plumbing Co",
    "categories": ["trades"],
    "tagline": "Licensed plumbers for the Eastern Suburbs",
    "description": "Family-run plumbing business servicing Bondi, Randwick and Coogee since 2004.",
    "phone": "0412 345 678",
    "website": "https://bondiplumbing.com.au",
    "suburb": "Bondi",
    "state": "NSW",
    "postcode": "2026",
    "service_area": "Eastern Suburbs, Sydney"
  }'

# 3. After the human clicks the confirmation email, publish it
curl -X PATCH https://ozfinda.com.au/api/v1/listings/bondi-plumbing-co \
  -H "Authorization: Bearer ozf_..." \
  -H "Content-Type: application/json" \
  -d '{"published": true}'
```

## Who should use this

- **Business owners' assistants**: an agent listing the business it works for.
- **Agencies and automations**: maintaining listings for Australian clients. Each account's plan caps how many listings it can own. The Free plan includes 1, and paid plans include more (see https://ozfinda.com.au/#pricing).
- **Search and recommendation agents**: read-only search needs no key.

Don't use it to bulk-import scraped business data, to list businesses you don't represent, or to list anything outside Australia. Those listings are refused, and repeated attempts get the account removed.

## Basics

| | |
| --- | --- |
| Base URL | `https://ozfinda.com.au/api/v1` |
| Format | JSON in, JSON out. Send `Content-Type: application/json`. Bodies max 64 KB. |
| Field names | `snake_case` |
| Auth | `Authorization: Bearer ozf_...` (API key). Cookies are never used, and CORS is open. |
| Machine spec | https://ozfinda.com.au/openapi.json |
| User-Agent | Send a descriptive one, e.g. `MyAgent/1.0 (+https://example.com)` |

## 1. Get an API key

### New account: `POST /agents/register`

```json
{ "name": "Sam Citizen", "email": "sam@bondiplumbing.com.au", "password": "a-long-password", "key_name": "Claude agent" }
```

Returns `201` with `account`, `api_key.key` (**shown once**, so store it) and `next_actions`. The account is a normal OZFINDA account. A human can log in at https://ozfinda.com.au/login with the same email and password and see everything the agent did.

We email a confirmation link to the address. **Nothing can be published until a human clicks it.** This is our main defence against throwaway-account spam. Use a real inbox the business controls.

### Existing account: `POST /api-keys`

```json
{ "email": "sam@bondiplumbing.com.au", "password": "a-long-password", "name": "Zapier" }
```

Or, with an existing key in the `Authorization` header, just `{ "name": "..." }`. Owners can also mint and revoke keys in the dashboard under **Account → API keys**.

- `GET /api-keys` lists live keys (prefix only).
- `DELETE /api-keys/{id}` revokes one.
- An account can hold up to 10 live keys.

## 2. Create a listing: `POST /listings`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `business_name` | string 2-120 | yes | Trading name |
| `categories` | string[] | yes | Category slugs from `GET /categories`. The first is primary. The Free plan allows 1. (`category`, a single slug, also works.) |
| `state` | string | yes | Australian state/territory: `NSW`, `VIC`, `QLD`, `WA`, `SA`, `TAS`, `ACT`, `NT`, or the full name |
| `username` | string 3-32 | no | Public link `https://ozfinda.com.au/<username>`: lowercase letters, numbers, hyphens. Derived from the name if omitted. |
| `tagline` | string ≤160 | no | One line |
| `description` | string ≤2000 | no | Plain text about the business. More than 2 links is refused. |
| `phone` | string ≤32 | no | Australian number (`04xx`, `02 xxxx xxxx`, `+61...`, `1300...`) |
| `email` | string | no | Public contact email |
| `website` | URL | no | **One listing per website across the directory** |
| `address` | string ≤200 | no | Street address |
| `suburb` | string ≤80 | no | |
| `postcode` | string | no | 4-digit Australian postcode |
| `service_area` | string ≤200 | no | e.g. "Greater Brisbane" |
| `highlights` | string[] ≤8 | no | Short "why choose us" lines, ≤120 chars each |
| `published` | boolean | no | Default `true`: go live as soon as every check passes |

Unknown fields are rejected with `400`, so typos surface instead of being silently dropped.

**Response `201`**:

```json
{
  "listing": {
    "username": "bondi-plumbing-co",
    "url": "https://ozfinda.com.au/bondi-plumbing-co",
    "status": "live",
    "moderation": { "status": "clear", "reason": null },
    "...": "every field above, plus id, dashboard_url, created_at, updated_at"
  },
  "next_actions": []
}
```

Always read `status` and `next_actions`. `next_actions` tells you exactly what's blocking a listing from going live.

### Listing status

| `status` | Meaning | What to do |
| --- | --- | --- |
| `live` | Public at `url`, in search and the sitemap | Nothing |
| `draft` | Saved, not public | Follow `next_actions`: usually `verify_email`, then `PATCH {"published": true}` |
| `pending_review` | Held for a human moderator. `moderation.reason` says why. | Wait, or fix the flagged content and PATCH again (re-runs the checks) |
| `rejected` | A moderator rejected it | Can't be published. Contact https://ozfinda.com.au/contact if it's a mistake. |

## 3. Read, update, delete

- `GET /listings/{username}`: anyone can read a live listing. With your key you also see your drafts plus `status` and `moderation`.
- `PATCH /listings/{username}`: send only the fields you're changing. Use `{"published": false}` to unpublish. `username` can't be changed over the API.
- `DELETE /listings/{username}`: permanent. Removes the listing and everything attached to it.
- `GET /me`: your account, plan, listing quota (`listings_used` / `listings_limit`) and all your listings with status.

Content edits re-run moderation. A live listing that's edited into something that fails is unpublished and held for review.

## 4. Search (no key needed)

`GET /listings?q=emergency+plumber&state=NSW&category=trades&limit=20&offset=0`

Returns `results[]` (username, url, business_name, category, tagline, suburb, state, service_area, phone, website, featured) and `next_offset`. Search combines full-text and semantic matching over live listings. Featured (paid) listings rank first when there's no `q`.

`GET /categories` returns every category slug and label.

## Moderation and spam rules

Every create and content edit goes through, in order:

1. **Structure.** `state` must be an Australian state or territory, and `postcode` must be a 4-digit Australian postcode. Anything else gets `422`.
2. **One listing per website.** A website already on OZFINDA gets `409 conflict`. If it's an unclaimed listing for your business, the response includes a claim link. If it's yours, it tells you which username to PATCH.
3. **Restricted content.** Adult services, drugs, weapons, unlicensed gambling, counterfeit goods and scams are held for review (`pending_review`).
4. **AI review.** A decision model checks that the listing is a genuine business operating in Australia. Confident failures (overseas business, SEO/link spam, gibberish, not a business) are refused with `422 content_policy` and **nothing is saved**. Borderline cases are saved but held for a human (`pending_review`).
5. **Email confirmation.** Nothing goes live until the account's email is confirmed.

What passes easily: a real Australian business described in plain, specific language, with a real suburb, state, postcode, Australian phone number and its own website.

What fails: keyword lists, city-name stuffing, links in the description, businesses based overseas, placeholder text, and listings for businesses you don't represent.

## Rate limits

| Scope | Limit |
| --- | --- |
| Registration | 5 per hour per IP |
| Key minting | 10 per hour per IP |
| Reads (search, GET) | 60 per minute per IP |
| Writes (POST/PATCH/DELETE) | 12 per minute per account, 30 per minute per IP |
| New listings | 20 per day per account (and your plan's listing cap) |

Over the limit you get `429 rate_limited` with a `Retry-After` header in seconds. Back off and retry. Don't hammer.

## Errors

Every error has the same shape:

```json
{ "error": "human-readable message", "code": "content_policy", "field": "state", "docs_url": "https://ozfinda.com.au/docs/agents" }
```

| HTTP | `code` | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | Bad JSON, missing or unknown field, invalid value (`field` names it) |
| 401 | `unauthorized` | Missing, invalid or revoked API key |
| 403 | `quota_exceeded` | Plan's listing allowance is used up (upgrade at https://ozfinda.com.au/dashboard/billing) |
| 403 | `forbidden` | e.g. publishing a moderator-rejected listing |
| 404 | `not_found` | No such listing, or it's not yours |
| 409 | `conflict` | Username or website already listed, or email already registered |
| 413 | `payload_too_large` | Body over 64 KB |
| 422 | `content_policy` | Refused by moderation (not Australian, spam, prohibited). Not saved. |
| 422 | `invalid_request` | Non-Australian state or postcode |
| 429 | `rate_limited` | Slow down, honour `Retry-After` |

## Recommended agent loop

1. `GET /me`. Check `can_add_listing` and existing listings, so you update rather than duplicate.
2. `POST /listings` with the fullest, most specific details you have.
3. If `422 content_policy`, read `error`. Don't retry the same content. Fix the underlying problem or stop.
4. If `status` is `draft` with `verify_email`, tell your human to click the email, then `PATCH {"published": true}`.
5. If `pending_review`, tell your human it's waiting for a moderator. Don't resubmit duplicates.
6. Keep the listing current with `PATCH` when hours, phone or services change.

## Discovery

- https://ozfinda.com.au/llms.txt: site summary for language models
- https://ozfinda.com.au/llms-full.txt: everything in one file
- https://ozfinda.com.au/openapi.json: OpenAPI 3.1 spec
- https://ozfinda.com.au/.well-known/api-catalog: RFC 9727 API catalog
- https://ozfinda.com.au/docs/agents.md: this playbook as markdown
