---
name: tapakly-headless-api
description: >-
  Attach an existing seller website to Tapakly inventory. Drive the seller API
  with the tapakly CLI. Two recipes share one contract: display Tapakly-managed
  listings, or synchronize an external CMS and publish when clearance allows it.
  Use when a seller keeps their hostname and hosting.
---

# Tapakly seller integration

You are wiring a seller's existing website to Tapakly. Read this skill and the
live OpenAPI document. Drive inventory through the `tapakly` CLI. Do not scrape
listing pages as a substitute, and do not copy older guides that tell you to pay
in `Authorization` or that a payment credential replaces the seller token.

## CLI

Install the single static binary for your OS/arch from the public R2 release
bucket (checksums.txt next to the archives). Build from this repo with
`go build -o tapakly .` in `clients/cli/`.

Every command takes `--json` for schema-stable agent output. The human default
is a table. Mutations send `If-Match` from `--etag` or from a GET of the listing.
Do not work around seller admission; the CLI prints the API refusal verbatim.

```bash
export TAPAKLY_API_ORIGIN=https://api.tapakly.com   # workspace / GET /developers
export TAPAKLY_API_TOKEN=tapakly_...               # never a plaintext config file
# or store the token in the OS keychain:
#   secret-tool store --label='Tapakly API token' service tapakly username token
#   security add-generic-password -s tapakly -a token -U -w
#   cmdkey /generic:tapakly /user:token /pass
```

A missing or invalid token fails with those names. The token selects the
workspace. Do not send `tenant`, `tenant_id`, `tenant_slug`, `X-Tenant` or
`X-Tenant-Id`.

```bash
tapakly --json vocabulary
tapakly --json listings create --file listing.json
tapakly --json listings update "$ID" --file patch.json
tapakly --json photos upload --listing "$ID" --file photo.jpg
tapakly --json listings publish "$ID"
tapakly --json listings withdraw "$ID"
tapakly --json listings delete "$ID"
tapakly --json published
tapakly --json published "$ID"
```

`listings publish` with a token that lacks the publish scope exits non-zero with
`forbidden`. A seller without clearance exits non-zero with
`seller_clearance_required` (or `private_owner_quota`). Do not retry those with
a payment header.

## Decisions you must keep

- The seller keeps their website hostname, DNS and hosting. Do not repoint that
  domain at Tapakly.
- Call the API origin shown in the workspace and in `GET /developers`
  (`TAPAKLY_API_ORIGIN`, default `https://api.tapakly.com`).
- Store the API token and the webhook signing secret on the website's server.
  Never put either in a browser bundle, a mobile client, or a public repository.
- Choose one authoritative inventory source and write it down in the integration:
  Tapakly, or the external CMS. Do not build a loop that writes a webhook echo
  back into Tapakly.
- Marketplace search and geo enrichment are disabled on the listings launch
  build. A 404 means the product is off. MCP and URL-import routes have been
  removed. Do not invent a client for these products.

## Inspect the site, then pick a recipe

1. Read the site's templates, CMS and any existing listing feed.
2. If Tapakly is already where the seller edits listings, use recipe A and only
   read `tapakly published`.
3. If the CMS is where the seller edits listings, use recipe B. Tapakly displays
   the published result; the CMS remains the writer.
4. Map fields with `tapakly vocabulary` (markets, areas, fields, currencies,
   kinds, forms, purposes). Unknown areas are not created by this API.
5. Ask the workspace owner for a least-privilege token from `/manage/developer`.
   `read` lists inventory. `write` creates and updates drafts. `publish` is issued
   only while seller admission is current. A private seller may publish one active
   listing. A 422 `seller_clearance_required` or `private_owner_quota`
   is the publication blocker. Do not retry it with a payment header.

## Authentication, grants and payment

Seller requests (the CLI sets this header):

```http
Authorization: Bearer tapakly_...
```

A workspace grant covers these calls. That is subscription-covered access:

- success with only the bearer token
- `403` `not_entitled` when the grant does not include the feature
- `429` `quota_exceeded` when the monthly allowance is spent, with `meta.upgrade_url` and the UTC month in `meta.cycle_start` / `meta.cycle_end`
- `429` `rate_limited` when the operator-configured rate window is spent
- withdrawal does not consume quota

An explicit paid retry is a different step, used only when a machine surface
returns `402` and a `WWW-Authenticate: Payment` challenge advertising
`header="Payment-Authorization"`:

```http
Authorization: Bearer tapakly_...
Payment-Authorization: Payment <MPP credential>
```

Keep the bearer token. Payment is not authentication. The launch seller routes
do not return that 402; do not send `Payment-Authorization` in place of the API
token. Public marketplace payment, when that product is enabled, still uses
`Authorization: Payment` on its own routes.

## Recipe A — display Tapakly-managed inventory

Server-side, with the read token:

```bash
tapakly --json published
```

Render `data`. Lists contain at most 100 rows; request the next page with
`--after=<last listing id>` until `data` is empty, then replace the cache with
the complete result. When a verified webhook names a listing, refetch
`tapakly --json published "$ID"`. A 404 means the listing was withdrawn or lost
clearance: delete it from the cache. Submit buyer inquiries with no token:

```bash
curl -sS -X POST "$TAPAKLY_API_ORIGIN/api/tenants/$TENANT_SLUG/leads" \
  -H 'content-type: application/json' \
  -d '{"contact":{"name":"Jane Buyer","email":"jane@example.com"},"message":"Is it available?","listing_id":"<uuid>"}'
```

That response is `201`. Lead commands are not part of this CLI.

## Recipe B — synchronize a CMS into Tapakly

Create from the server. The same `reference_code`, `content_source` and payload
return the same listing (`200`). A different payload for that pair is `409`
`reference_payload_conflict`. A different source is `409` `reference_source_conflict`.

```bash
cat > listing.json <<'JSON'
{
  "reference_code": "cms-123",
  "content_source": "external-cms:example",
  "kind": "standing",
  "form": "villa",
  "title": {"en": "Example villa"},
  "latitude": -8.9,
  "longitude": 116.3,
  "listing_currency": "IDR",
  "market_id": "<canonical-market-uuid>",
  "area_id": "<canonical-area-uuid>",
  "prices": [{"purpose": "sale_freehold", "currency": "IDR", "amount_minor": 100000}]
}
JSON
tapakly --json listings create --file listing.json
```

Success is `200` with `data` and an `etag`. Prices are integer minor units in the
currency the seller quotes. There is no lead display currency.

Updates, publish, withdraw, delete and photo calls require `If-Match` set to the
ETag (quotes included) or `*`. The CLI fetches the current revision when `--etag`
is omitted. Missing it on the raw HTTP API is `428`. A stale revision is `412`
with `current_revision`: GET the listing, reconcile, retry. Do not force your
local copy over the current revision.

```bash
tapakly --json listings update "$ID" --file patch.json
tapakly --json listings publish "$ID"
tapakly --json listings withdraw "$ID"
tapakly --json listings delete "$ID"
```

`422` lists blockers (`seller_clearance_required`,
`contact_required`, `private_arrangement`, `private_owner_quota`). `DELETE`
removes a draft and returns `200` with `deleted: true`. A published listing
returns `409` `withdraw_instead`. There is no `204`.

Photos: `tapakly photos upload` runs prepare → upload → complete. Keep the seller
bearer token on the seller API origin; the CLI does not send it to the upload
host.

```bash
tapakly --json photos upload --listing "$ID" --file villa.jpg --alt "Pool"
```

Until media delivery is configured the status is `422` `cloudflare_not_configured`.

## Webhooks

Register the endpoint in `/manage/developer`. The signing secret is not the API
token. Deliveries:

```http
POST /your-cache-invalidation
content-type: application/json
x-tapakly-event: listing_updated
x-tapakly-signature: sha256=<hex HMAC-SHA256 of the raw body>
```

```json
{"event":"listing_updated","listing_id":"<uuid>","tenant_id":"<uuid>","occurred_at":"2026-09-22T00:00:00Z"}
```

Events on the wire are `listing_created`, `listing_updated` and `listing_deleted`.
The workspace test button sends `integration_test` with `listing_id` null. Verify
it, then ignore it. Do not create a listing from it.

Recovery, using current API state rather than the event body:

- Invalid signature: leave the cache unchanged.
- Duplicate `(event, listing_id, occurred_at)`: no second write.
- Out-of-order or partial events: mark the id dirty and refetch. The GET wins.
- Missed events: refetch `tapakly --json published` and replace the cache.
- Non-2xx from your receiver: Tapakly retries that delivery. Your handler stays idempotent.
- A webhook is never a reason to write Tapakly inventory.

The executable cache is `examples/external_site/cache.ex`.

## Where to read the live contract

- `GET /developers` — human guide
- `GET /developers/openapi.json` — statuses, parameters and payment discovery
- `GET /developers/skill.md` — this file
- `GET /llms.txt` — agent entry point

Workspace setup is `/manage/developer` on the seller's Tapakly host.
