---
name: haggin
description: Run a SaaS seller's price negotiations on HAGGIN (hagg.in) through its API. Use this whenever the user wants to recover a trial that just ended, win back a customer about to churn, answer the offers waiting in their HAGGIN inbox, open a "make an offer" page for a launch or a Black Friday, close a deal (payment link, what happens next, mark as paid), or get HAGGIN events into their own systems with webhooks. Also use it when they mention HAGGIN, a Haggin, hagg.in, or letting customers make an offer on their pricing. The agent reads, interprets and drafts on its own. It never accepts, counters, rejects or publishes without the seller's explicit yes.
version: 1.0.1
metadata:
  openclaw:
    requires:
      env: [HAGGIN_API_KEY]
    primaryEnv: HAGGIN_API_KEY
    envVars:
      - name: HAGGIN_API_KEY
        required: true
        description: A HAGGIN API key (hg_live_...), created at hagg.in/app/settings/api on a Pro or Business plan.
    homepage: https://hagg.in/docs/api
---

# HAGGIN

HAGGIN lets a SaaS company negotiate its prices with its customers. The seller publishes a **Haggin**: a page with its own link where a customer builds a structured offer (price, period, seats, commitment, extras such as a testimonial or a post to their audience). Offers land in the seller's inbox. The seller accepts, counters or passes. When both sides agree, the seller pastes a payment link from its own checkout, the buyer accepts the frozen terms, and the deal is **won**. HAGGIN never touches the money.

Vocabulary, used exactly like this:

- **Haggin**: a negotiation page, public or private, temporary or permanent. Not "campaign".
- **Offer**: one complete proposal, from either side. Immutable. A counteroffer is the next offer in the sequence.
- **Negotiation**: the thread of offers between one buyer and one seller about one product.
- **Deal**: an accepted offer with frozen terms. `agreed` (waiting for the payment link), `payment_ready`, `won` (the buyer accepted the terms).
- **Standard price**: the seller's normal published price. Every offer is read against it.

There is no chat. You cannot send the buyer a message. What you can send is an offer, a counteroffer, a yes, or a no.

## Setup

You need a personal API key. The seller creates it at `https://hagg.in/app/settings/api`. The selling API is part of the Pro and Business plans. Keys come in two kinds: **full access** (does everything) and **read only** (can look, can't touch). A key starts with `hg_live_` and is shown once.

Read the key from the environment (`HAGGIN_API_KEY`) or from wherever the user keeps secrets. If they paste it into the conversation, use it and never repeat it back.

Every request:

```
Base URL:       https://hagg.in/api/v1
Authorization:  Bearer hg_live_...
Content-Type:   application/json    (on writes)
```

JSON in, JSON out, `snake_case`. Money is in minor units: `9900` is 99.00. Times are ISO 8601 in UTC. Ids are UUIDs. One thing comes back under `data`; lists add `pagination`.

Make `GET /me` your first call. `data.selling.enabled` says whether the selling endpoints will answer:

| `selling.reason` | What it means | What to tell the seller |
| --- | --- | --- |
| `mode_not_allowed` | The key was not created for selling. | Create a key at Settings, API keys. |
| `no_organization` | The account has no company yet. | Name the company in the app first. |
| `plan_required` | The company is not on Pro or Business. | The API needs Pro. Keys are kept and start answering again on Pro. |

`GET /me/billing` says whether a Haggin could be published right now (`publish.allowed`, `publish.max_duration_days`, `publish.permanent_allowed`). Check it before you promise to publish anything. It answers for every plan, so on a Free Trial or Campaign account `publish.allowed` can be `true` while the selling endpoints still refuse with `plan_required`. Through the API, only `publish.source: "plan"` means you can publish.

## The rule

**The seller decides. You prepare.**

Do freely, without asking:

- Read anything: Haggins, the inbox, negotiations, deals, deliveries.
- Interpret offers, rank them, explain them.
- Draft: a counteroffer, a private Haggin, a launch page, a reply plan.
- Create and edit Haggin **drafts**. A draft is private and costs nothing.

Do only after the seller says yes to that exact action, with those exact terms:

- Accept, counter, reject or close a negotiation. Bulk reject.
- Publish a Haggin. Close a Haggin.
- Put a payment link on a deal. Send "what happens next" instructions.
- Change or rotate the webhook endpoint.

Why: every one of these emails a real person, spends the seller's allowance, or commits their company to a price. A wrong counteroffer is a real offer the buyer can accept. HAGGIN's own principle is that automation never invents commercial terms on the seller's behalf.

What "yes" means here:

- One yes per negotiation, per action. "Accept anything above 80%" is a policy, not a yes. Apply it to build your proposal, then get the yes for each offer it touches.
- The seller has seen the terms you are about to send: amount, period, seats, commitment, extras, time limit. Show the request body in your proposal. Send exactly that.
- Every number in a counteroffer is the seller's, or one you proposed and they confirmed. Never fill a gap with a guess.
- Mark a deal as paid only when the seller tells you the money landed. It is their bookkeeping.

How to propose, per offer, in one compact block:

```
Ana Ruiz, Bravo Labs (38K on X). Team plan, 10 seats.
Offered: $590/month for 10 seats, 12 months, + testimonial.
Standard: $99/seat/month, so $990. Ratio 0.60. Potential: medium.
Your note on testimonials: "Only if they have an audience". She has one.
Recommend: counter at $9,480/year (790 a month) for 10 seats, annual commitment, 1 year, keep the testimonial.
  {"amount_cents": 948000, "billing_period": "year", "licenses": 10, "annual_commitment": true,
   "price_term_months": 12, "items": [{"party": "buyer", "type": "testimonial"}]}
Say: accept / counter as above / counter at another number / pass.
```

Then stop and wait.

## Reading an offer

An offer is a set of numbers, not a message. The fields, and how to read them:

- `amount_cents`: the **total for the period, for the whole scope**. `59000` with `licenses: 10` and `billing_period: "month"` is 590 a month for ten seats, not per seat.
- `billing_period`: `month`, `year` or `one_time`. `annual_commitment: true` means the buyer commits to a year. `is_lifetime: true` means one payment, forever.
- `licenses`: seats, when the product is `per_seat` or the seller opened that lever.
- `price_term_months`: how long the agreed price holds. `null` means no limit. After it, the regular price applies. A monthly price is limited in months, a yearly one in whole years.
- `items[]`: the extras. `party: "buyer"` is what the buyer gives (a `content` item carries a `format`, `custom` ones carry the seller's own `label`). `party: "seller"` is what the seller adds (an onboarding call, extra seats). `detail` is the buyer's short note on that item.
- `custom_terms`: free text, only if the seller allowed custom offers. It is a note, not the deal.
- `standard_price_cents`: the standard price **per unit, in the product's period, when the offer was made**. The history stays honest even if the seller changes prices later.

**Ratio.** The inbox list gives `interpretation.ratio` and `interpretation.potential` for the current offer. The negotiation detail does not, so for older offers or your own checks compute it the way HAGGIN does:

```
expected = standard_price_cents, moved to the offer's period (month to year: x12, year to month: /12)
           x licenses if the product is per_seat (at least 1; flat products ignore seats)
ratio    = amount_cents / expected
```

Lifetime offers have no ratio. Express them as months at the standard price instead (`amount / monthly standard / seats`).

Buckets: below 0.5, 0.5 to 0.8, above 0.8. Ratio 1 or more is full price or better.

**Potential** (`low`, `medium`, `high`) is HAGGIN's qualitative read of the whole deal, not of the price: ratio, seats (10 and 20 are the steps), annual or lifetime, strategic extras (content, case study, referral, CEO call), a testimonial or one of the seller's own extras, the buyer's biggest audience (5K and 20K are the steps), and whether the buyer named a company.

**The seller's own criteria** live on the Haggin. `GET /selling/haggins/{id}` returns each option with a buyer-facing `hint` and the seller's `internal_notes` ("minimum 8K followers"). Read them once per Haggin and apply them when you read offers. They are the closest thing to the seller's rules.

**The buyer.** `buyer.channels` says where they publish and how big the audience is. `buyer.email` is `null` until a deal is agreed and the buyer consented to share it. HAGGIN carries the messages until then. Don't try to work around it.

Tell the seller the whole picture: what they get paid, for how long, what the buyer gives, and what it is worth against the standard price.

## Playbooks

### Recover the trial that just ended, or win back a customer

One person, one private link. Only the invited email can make an offer on it, the link can't be guessed, and it is not indexed.

1. `GET /me`: `selling.enabled` must be true. Then `GET /me/billing`. If `publish.allowed` is false, or `publish.source` is not `"plan"`, say so and stop: the reason and `upgrade_hint` are in the answer.
2. Ask for what you don't have: the person's name and email, the product and its standard price (with currency, period and whether it is flat or per seat), which levers are open, whether the price should have a time limit, and how long the door stays open.
3. `POST /selling/haggins` with `kind: "private"`, `invited_name`, `invited_email`, one product, the options, `price_term_months` if wanted, `duration_days` and `on_expiry`. It comes back as a `draft`.
4. Show the draft: title, product and price, what can be negotiated, time limit, duration. Ask for the yes to publish.
5. `POST /selling/haggins/{id}/publish`. Then give the seller `public_url`. **HAGGIN does not email the invited person.** The seller sends the link, from their own inbox, in their own words.

Suggested defaults when the seller has no opinion: `duration_days: 14`, `on_expiry: "close"`, `price_term_months: 12` on a monthly product, licenses and annual commitment on, testimonial on with a hint. Say they are defaults.

### Answer this week's offers

1. `GET /selling/negotiations?view=attention&sort=best`. `attention` is what waits on the seller. `best` puts potential first, then ratio, then money.
2. For each Haggin involved, `GET /selling/haggins/{id}` once, for the standard prices, the levers and the internal notes.
3. For each negotiation, `GET /selling/negotiations/{id}` when there is history: `offers[]` is the whole back and forth, oldest first. A buyer who already moved once is worth a different answer than a first offer.
4. Present all of them in one message, best first, each in the block above. Then wait.
5. For each yes, send one write with a fresh `Idempotency-Key`, then report what came back (`status`, and `deal.id` after an accept).

The three answers:

- **Accept**: `POST /selling/negotiations/{id}/accept`. Creates the deal. The buyer gets an email. If the amount is zero, the buyer is sent straight to the terms: the deal starts at `payment_ready`, marked paid, and never emits `deal.payment_ready`.
- **Counter**: `POST /selling/negotiations/{id}/counter` with the same shape as an offer. `amount_cents` and `billing_period` are required; without `billing_period` the request is a `validation_error`. Rules the server applies: `annual_commitment: true` makes the period `year`; `is_lifetime: true` makes it `one_time`; otherwise the period is the product's, whatever you send. `is_lifetime: true` also clears `annual_commitment`. `price_term_months` on a yearly price must be a multiple of 12, and is dropped on lifetime and one-time. The seller may set a time limit, change it or drop it. Add the seller's own items with `party: "seller"`. Extras you ask of the buyer don't have to be switched on for the Haggin. A `custom` item must be one of the Haggin's own extras, by `key`, and is always `party: "buyer"`. A zero amount is fine as long as something else is on the table. A term that isn't whole years on a yearly price, an unknown custom `key` or an item of type `licenses`, `annual_commitment` or `lifetime` come back as `request_rejected`, not `validation_error`. Sending an offer's `items[]` back as the API returned them works: `null` in `format`, `key`, `label` and `detail` is read as absent.
- **Pass**: `POST /selling/negotiations/{id}/reject`, optional `reason` (`too_low`, `not_our_audience`, `spam`). Ends the negotiation. The buyer reads the reason in the email.

`POST /selling/negotiations/{id}/close` walks away from an open negotiation whoever's move it is. Rejecting is for an offer on the table; closing is for a thread the seller wants to end.

A counter is a real offer. If the buyer accepts it, it is a deal. Send only what the seller confirmed.

### Run a launch, a Black Friday, or a permanent "make an offer" page

Same as a private Haggin with `kind: "public"`, several products if needed (up to 5, one currency), an `intro` for the page, `duration_days` (or `null` for permanent, Pro and Business only) and `on_expiry` (`keep_open` lets open negotiations finish after the door closes; `close` ends them and tells the buyers). `allow_custom_offers` lets buyers add free text.

Show the seller `public_url` after publishing. The page carries their company name, their products, their standard prices and what can be negotiated.

Editing: `PUT /selling/haggins/{id}` **replaces** the editable part, products and options included. Read it, change what you need, send the whole thing back. A product that already has offers keeps its name, description, price, currency, period and pricing model, and can't be removed; only its `info_url` can change. Once one product is locked, every product's currency is pinned to it. Every editable field is replaced: leave `price_term_months` out and it resets to `null`, leave `deal_terms` out and the default text comes back. A live Haggin's end date moves with `expires_at` (ISO 8601 in UTC, ending in `Z`). Closed and expired Haggins can't be edited, and publishing one answers `request_rejected`.

Closing early: `POST /selling/haggins/{id}/close`. For good. The slot is free at once. A closed draft is dead too: it can't be edited or published later.

### Close the deal

1. `GET /selling/deals?status=agreed`: deals waiting for a payment link. `buyer.email` is here once the buyer consented to share it. The money is under `accepted_offer` (`amount_cents`, `currency`, `billing_period`), not at the top level.
2. The seller gives you the link from their own checkout (Stripe, Paddle, anything), `https://` only. `PUT /selling/deals/{id}/payment-link` with `{"url": "https://..."}`. The first time, the deal becomes `payment_ready` and the buyer gets an email. A deal with a zero amount has no link: the buyer goes straight to the terms.
3. The deal becomes `won` when the buyer accepts the frozen terms in the app. Watch for it with the `deal.won` webhook, or poll `GET /selling/deals?status=won`.
4. Once won, `POST /selling/deals/{id}/instructions` tells the buyer what happens next (access, onboarding, invoice). Every call sends the email again, with replies going to the person whose key you use. Only when the seller has written or approved the text.
5. `POST /selling/deals/{id}/paid` with `{"paid": true}` when the seller says the money landed. `false` undoes it. Won deals with an amount only; a zero-amount deal is already marked paid.

### Clean the noise

1. Find it: `GET /selling/negotiations?view=attention&max_ratio=0.3`, or `q=` for a name or company, or `item=` for offers where the buyer gives a given extra. `min_licenses`, `min_ratio` and `haggin_id` narrow further. A filter value the API doesn't know answers `validation_error`.
2. List the candidates with the reason each one is noise. Every rejected buyer gets an email, and in bulk it carries no reason, so say so.
3. On the seller's yes, `POST /selling/negotiations/bulk-reject` with `negotiation_ids` (up to 200). The answer is `{rejected, skipped}`. Skipped ones were not waiting on the seller, were not theirs, or don't exist. Duplicate ids are dropped and counted in neither.

A low offer is still information about what people would pay. Suggest a counter before a pass when the buyer looks real.

### Get told when something happens

One endpoint per company. HAGGIN POSTs a signed JSON envelope for each event the endpoint subscribed to.

1. `PUT /selling/webhook-endpoint` with `url` (https, a public hostname, never an IP; one that doesn't resolve or resolves to a private address is refused with `request_rejected`), `enabled` and `events`. An empty `events` list means every event, including ones added later. The answer carries `secret` (`whsec_...`) for full-access keys. Put it in the seller's system; don't paste it around. `POST /selling/webhook-endpoint/rotate-secret` when it leaks: the old one dies at once. There is no delete; `enabled: false` switches it off.
2. `POST /selling/webhook-endpoint/test` queues a `ping` and returns the delivery still `pending`; it is sent right after. `GET /selling/webhook-deliveries` shows what the endpoint answered. `POST /selling/webhook-deliveries/{id}/retry` tries one again now.
3. Verify signatures with any Standard Webhooks library, or by hand: headers `webhook-id`, `webhook-timestamp`, `webhook-signature` (`v1,<base64>`); the signed string is `{id}.{timestamp}.{raw body}`; the HMAC-SHA256 key is the base64-decoded part after `whsec_`; reject timestamps more than five minutes away.
4. Retries: eight attempts over about three days. After 50 consecutive failures the endpoint is paused and the seller gets an email; set it `enabled: true` again to resume.

Events: `haggin.published`, `haggin.closed`, `offer.received`, `offer.countered`, `offer.accepted`, `offer.rejected`, `negotiation.closed`, `deal.payment_ready`, `deal.won`, `deal.paid`. `negotiation.closed` also fires when a negotiation expires. In `data`, the `haggin`, `product`, `buyer`, `offer` and `deal` shapes are the API's; `negotiation` is a shorter summary (`offers_count`, no `offers[]`), see the reference.

No server to receive them? Poll `view=attention` instead. Webhooks tell the seller's systems; they never send anything to buyers.

## Writes done right

- **Idempotency.** Send an `Idempotency-Key` header (a fresh UUID per action) on every write. A retry with the same key and body gets the same answer for at least 24 hours, marked `Idempotent-Replayed: true`, and does nothing twice: no second email, no second offer. A refused request (4xx) is remembered too: fix the body and send it with a new key. The same key with a different method, path or body is refused (`idempotency_key_reused`). `idempotency_in_progress` means the first attempt is still running: wait a moment, retry with the same key; after about two minutes the key is released. After a `500`, the key is released: retry with the same key.
- **Rate limits.** Per key, per minute: 120 requests, 30 of them writes. Every authenticated response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; on writes they may show the write ceiling of 30. Refused writes count too. A `429` has `Retry-After` in seconds. Wrong keys are limited per address too: 20 failures in five minutes.
- **Pagination.** `limit` (1 to 100, default 50) and `offset`. Lists answer `pagination.total` and `has_more`. The inbox filters and sorts over the 500 most recently active negotiations that match `view`, `haggin_id` and `product_id`: narrow with those first. There, `total` never exceeds 500.
- **Bodies** up to 100 KB. Where a text is empty you get `null`, and you can send `null` back. Everything else is in `references/api.md`: the error table below skips three codes you only get with a broken request (`invalid_json` 400, `invalid_idempotency_key` 400, `body_too_large` 413).
- **Errors** always look the same: `error.code` (stable, branch on it), `error.message` (for people), `error.fields` on validation errors, `error.request_id` (quote it when writing to HAGGIN).

| Code | Status | What to do |
| --- | --- | --- |
| `invalid_api_key` | 401 | The key is missing, malformed or revoked. Ask for a valid one. |
| `insufficient_access` | 403 | A read-only key tried to write. Ask for a full-access key. |
| `plan_required` | 403 | The company is not on Pro or Business. Say so. |
| `no_organization` | 403 | No company on the account. The seller names one in the app. |
| `mode_not_allowed` | 403 | The key can't sell. Create one at Settings, API keys. |
| `no_allowance` | 403 | Nothing left to publish with. `GET /me/billing` says why. |
| `not_found` | 404 | Doesn't exist, or isn't this company's. Same answer for both. |
| `idempotency_in_progress` | 409 | The first request with this key is still running. Wait, retry. |
| `validation_error` | 422 | `fields` says which field and why. Fix the body. |
| `request_rejected` | 422 | Valid, but not allowed right now: not the seller's move, already closed, already won, or a body the Haggin's rules refuse (term not in whole years, unknown extra). The message says. Tell the seller, don't retry. |
| `idempotency_key_reused` | 422 | Same key, different body. Use a fresh key. |
| `rate_limited` | 429 | Wait `Retry-After` seconds. |
| `internal_error` | 500 | HAGGIN's fault. Retry with the same idempotency key. Keep `request_id`. |

## Reference

`references/api.md` has every endpoint with its query parameters, body fields, response shape and limits, verified against the running code. Open it when you need a field you don't see here, or before building a body you haven't sent before. The live spec is at `https://hagg.in/api/v1/openapi.json` and the human reference at `https://hagg.in/docs/api`.

---

What follows is `references/api.md` from the skill folder, in the same page.

# HAGGIN selling API, endpoint by endpoint

Base URL `https://hagg.in/api/v1`. Header `Authorization: Bearer hg_live_...` on every call. Checked against the route handlers and validation schemas of the running code (September 2026). The live spec is `https://hagg.in/api/v1/openapi.json`.

Contents:

1. Conventions
2. Shapes (what comes back)
3. Account: `/me`, `/me/profile`, `/me/billing`, `/selling/organization`
4. Haggins: `/selling/haggins`
5. Negotiations: `/selling/negotiations`
6. Deals: `/selling/deals`
7. Webhooks: `/selling/webhook-endpoint`, `/selling/webhook-deliveries`
8. Webhook payloads
9. Errors

## 1. Conventions

- **Access.** `read` endpoints work with any key. `write` endpoints need a full-access key. Everything under `/selling` needs a company on Pro or Business; `/me` works for any key.
- **Bodies** are JSON, `snake_case`, up to 100 KB. Unknown keys are ignored. Optional texts accept `null` as "leave it empty", inside `items[]` too.
- **Responses**: `{ "data": ... }`. Lists: `{ "data": [...], "pagination": { "limit", "offset", "total", "has_more" } }`. Some `data` can be `null` (noted below).
- **Pagination**: `limit` 1 to 100 (default 50), `offset` from 0 (up to 1,000,000).
- **Ids** are UUIDs. A path id that is not a UUID answers `404`.
- **Idempotency**: `Idempotency-Key` header on writes, up to 255 characters, remembered at least 24 hours per key. Replays carry `Idempotent-Replayed: true`. A refused request (4xx) is replayed too: fix the body and use a new key. A key still in progress is released after about two minutes.
- **Rate limits** per key per minute: 120 requests, 30 writes. Headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (unix seconds) on every authenticated response; on writes they can show the write ceiling (30). Refused writes count too. `429` carries `Retry-After` (seconds).
- Every response carries `X-Request-Id`.
- Enum values used throughout:
  - `currency`: `USD`, `EUR`, `GBP`
  - `billing_period`: `month`, `year`, `one_time`
  - `pricing_model`: `flat`, `per_seat`
  - option and item `type`: `licenses`, `annual_commitment`, `lifetime`, `content`, `testimonial`, `ceo_call`, `referral`, `case_study`, `custom`
  - content `format`: `x_post`, `linkedin_post`, `instagram`, `tiktok`, `youtube`, `blog_post`, `newsletter`, `podcast`, `other`
  - Haggin `status`: `draft`, `live`, `closed`, `expired`; `kind`: `public`, `private`; `on_expiry`: `keep_open`, `close`
  - negotiation `status`: `waiting_for_seller`, `waiting_for_buyer`, `accepted`, `rejected`, `closed`, `expired`
  - offer `status`: `pending`, `countered`, `accepted`, `rejected`, `withdrawn`, `expired`; `author`: `buyer`, `seller`
  - deal `status`: `agreed`, `payment_ready`, `won`; `payment_status`: `pending`, `paid`. A zero-amount deal starts at `payment_ready`, `payment_status: "paid"`, `paid_at` set, and never emits `deal.payment_ready`.
  - seller close `reason`: `too_low`, `not_our_audience`, `spam`

## 2. Shapes

**Product**: `id`, `name`, `standard_price_cents`, `currency`, `billing_period`, `pricing_model`. On a Haggin detail also `description`, `info_url`, `negotiation_count` (above zero, the product is locked).

**Option** (on a Haggin detail): `type`, `key` (custom only), `label` (custom only), `enabled`, `hint` (buyer-facing), `internal_notes` (seller only), `config: { formats: [] }` (content only; empty means all formats).

**Offer**: `id`, `seq` (1 is the first), `author`, `status`, `amount_cents`, `currency`, `billing_period`, `licenses` (nullable), `is_lifetime`, `annual_commitment`, `price_term_months` (nullable), `custom_terms` (nullable), `standard_price_cents` (the standard price per unit when the offer was made), `created_at`, `items[]` each `{ party, type, format, key, label, detail }`.

**Buyer**: `id`, `name`, `company`, `role`, `website`, `channels[]` each `{ format, handle, audience }`. On negotiation details and deals also `email`, which is `null` until a deal is agreed and the buyer consented to share it. The inbox list never carries `email`.

**Haggin summary** (inside a negotiation): `id`, `title`, `slug`, `status`, `kind`, `price_term_months`, `public_url`, `published_at`, `expires_at` (`null` means permanent), `closed_at`.

**Haggin, seller view** (list and detail): the summary plus `intro`, `invited_name`, `invited_email`, `duration_days`, `on_expiry`, `created_at`, `updated_at`, `url` (the app page). The list adds `products[]` and `negotiations: { total, open, won }` (confirmed negotiations only; `won` counts negotiations that ended in a deal, whatever the deal's status). `products[].negotiation_count` counts confirmed negotiations too. The detail adds `goal`, `business_context`, `allow_custom_offers`, `deal_terms`, `max_duration_days`, `products[]` (extended), `options[]`.

**Negotiation core**: `id`, `status`, `url`, `closed_by` (`buyer`, `seller`, `system` or null), `close_reason` (`rejected`, `closed_by_seller`, `withdrawn_by_buyer`, `haggin_closed`, `haggin_expired`, `expired`), `close_detail` (the seller's `too_low`, `not_our_audience`, `spam` or the buyer's `too_expensive`, `found_alternative`, `just_looking`), `expires_at`, `created_at`, `last_activity_at`, `closed_at`.

**Negotiation, list item**: the core plus `haggin: { id, title, slug, kind, status }`, `product`, `buyer` (no email), `offer` (the current one), `interpretation: { ratio, potential }` (`ratio` is `null` when not comparable, such as lifetime), `deal` (summary or null).

**Negotiation, detail**: the core plus `haggin` (summary), `product`, `buyer` (with `email` rule), `offer` (current, nullable), `offers[]` (every offer, oldest first), `deal` (summary or null). No `interpretation` on the detail: compute the ratio yourself (see SKILL.md).

**Deal summary**: `id`, `status`, `payment_status`, `payment_link_url`, `terms_version`, `agreed_at`, `won_at`, `paid_at`, `url`.

**Deal, list item**: the summary plus `negotiation_id`, `haggin: { id, title, slug }`, `product`, `buyer`, `accepted_offer`.

**Deal, detail**: the summary plus `negotiation_id`, `payment_provider` (`manual_link` today), `payment_link_added_at`, `terms_text` (the frozen terms), `instructions`, `instructions_sent_at`, `product`, `buyer`, `accepted_offer`, `terms_acceptance: { accepted_at, terms_version }` or null.

**Webhook endpoint**: `id`, `url`, `enabled`, `events[]` (empty means all), `secret` (`null` for read-only keys), `disabled_reason` (`too_many_failures` or null), `consecutive_failures`, `last_delivery_at`, `last_delivery_status`, `created_at`, `updated_at`.

**Webhook delivery**: `id`, `event_type`, `status` (`pending`, `delivered`, `failed`, `exhausted`), `attempts`, `next_attempt_at`, `last_attempt_at`, `response_status`, `response_body` (first 1 KB), `error`, `delivered_at`, `created_at`, `payload` (the envelope that was sent).

**Entitlements** (`/me/billing` and inside `/me`): `plan` (`free_trial`, `campaign`, `pro`, `business`), `plan_source` (`subscription`, `grant`, `none`), `plan_expires_at`, `subscription_status`, `max_live_haggins` (`null` is unlimited), `live_haggins`, `available_slots`, `trial_remaining`, `unused_campaign_purchases`, `publish`: either `{ allowed: true, source: "plan" | "campaign_purchase" | "trial", max_duration_days, permanent_allowed }` or `{ allowed: false, reason, upgrade_hint }`.

## 3. Account

### GET /me (read, any key)

Who the key belongs to and what it can do right now. Response: `{ user: Profile, key: { id, access: "read" | "full", modes: ["selling"] }, selling: { enabled: true, organization (null until the company exists), entitlements } | { enabled: false, reason: "mode_not_allowed" | "no_organization" | "plan_required", message } }`.

### PUT /me/profile (write, any key)

What the other side sees about the person behind the key. Replaces the whole profile. Body: `name` (required, up to 120), `company` (up to 120), `role` (up to 80), `website` (a URL up to 200, `https://` added if missing), `channels[]` (up to 9) each `{ format, handle (up to 120), audience (integer or null) }`, stored as sent. Response: Profile (`id`, `email`, `name`, `company`, `role`, `website`, `channels`).

### GET /me/billing (read, any key)

The plan of the company the key sells as, and whether a Haggin could be published right now. Response: Entitlements, or `data: null` while there is no company. Read only: paying and changing plan happen in the app.

### GET /selling/organization (read)

Response: `{ id, name, slug, website, created_at }`.

### PUT /selling/organization (write)

Replaces both fields. Body: `name` (2 to 80), `website` (up to 200, `https://` added if missing; leave it out and it is cleared). Response: the organization.

## 4. Haggins

### GET /selling/haggins (read, paginated)

Newest first. Query: `status` (`draft`, `live`, `closed`, `expired`), `kind` (`public`, `private`), `limit`, `offset`. Response items: Haggin, seller view, list item.

### POST /selling/haggins (write) → 201

Creates a draft. Nothing is public and nothing is spent until publish. Body:

| Field | Required | Notes |
| --- | --- | --- |
| `title` | yes | 2 to 80 characters. |
| `kind` | yes | `public` or `private`. |
| `invited_name` | private | Up to 120. |
| `invited_email` | private, required | Only this address can make an offer. Both `invited_*` are dropped on a public Haggin. |
| `intro` | no | Up to 280. Shown on the page. |
| `goal` | no | `get_customers`, `convert_trials`, `recover_churn`, `test_pricing`, `run_launch`, `other`. |
| `business_context` | no | `launching`, `low_conversion`, `losing_on_price`, `many_customers`, `experimenting`, `other`. |
| `products[]` | yes, 1 to 5 | Each: `name` (1 to 80), `standard_price_cents` (1 to 10,000,000,000), `currency`, `billing_period`, `pricing_model`, optional `description` (up to 2000), `info_url` (http or https, up to 2048). All products share one currency. |
| `options[]` | yes (can be empty) | Each: `type`, `enabled`, optional `hint` (up to 280), `internal_notes` (up to 500), `config: { formats: [] }` for `content`. Built-in types once each. `custom` needs `key` (6 to 24 lowercase letters and digits, made up by you, stable for the life of the extra) and `label` (up to 40); up to 5 custom extras, no repeated `key` or `label`. |
| `allow_custom_offers` | yes | Free text on offers. |
| `price_term_months` | no | 1 to 120, or `null` (default). The starting time limit on agreed prices. |
| `duration_days` | yes | 1 to 365, or `null` for permanent (Pro and Business). Capped at publish by the plan's `max_duration_days`; on a capped plan `null` becomes the cap, it is not refused. |
| `on_expiry` | yes | `keep_open` or `close`. |
| `deal_terms` | no | Up to 4000. HAGGIN's default terms apply when empty. |

Response: Haggin, seller view, detail.

Example, a private one:

```json
{
  "title": "Acme, let's talk",
  "kind": "private",
  "invited_name": "Ana",
  "invited_email": "ana@bravolabs.com",
  "products": [{ "name": "Team plan", "standard_price_cents": 9900, "currency": "USD", "billing_period": "month", "pricing_model": "per_seat" }],
  "options": [
    { "type": "licenses", "enabled": true },
    { "type": "annual_commitment", "enabled": true },
    { "type": "testimonial", "enabled": true, "internal_notes": "Only if they have an audience" }
  ],
  "allow_custom_offers": true,
  "price_term_months": 12,
  "duration_days": 14,
  "on_expiry": "close"
}
```

### GET /selling/haggins/{id} (read)

Response: Haggin, seller view, detail. What comes back can be sent to PUT as it is.

### PUT /selling/haggins/{id} (write)

Replaces the editable part. Body: `title`, `intro`, `products[]` (the full list after the edit; existing ones carry their `id`, new ones don't, missing ones are removed), `options[]` (the full list; what you leave out is gone, `internal_notes` included), `allow_custom_offers`, `price_term_months`, `on_expiry`, `deal_terms`, and `expires_at` (ISO 8601 in UTC ending in `Z`, or `null`; only applied on a live Haggin, must be in the future and within `max_duration_days` from publish when there is one). Every field listed is replaced: leave `price_term_months` out and it resets to `null`, leave `intro` out and it is cleared, leave `deal_terms` out and the default text returns. `kind`, `invited_*`, `duration_days`, `goal` and `business_context` can't change here. A product with `negotiation_count` above zero keeps its name, description, price, currency, period and pricing model whatever you send, only `info_url` changes, and it can't be removed; once one product is locked, every product's currency is pinned to it. A `products[].id` that isn't this Haggin's answers `not_found`. Closed and expired Haggins answer `request_rejected`. Response: the detail.

### POST /selling/haggins/{id}/publish (write, emails the seller's team)

Goes live and spends a slot (Pro, Business) or an allowance (trial, Campaign). Only from `draft`; live, closed and expired answer `request_rejected`. `duration_days` is capped by the plan; `expires_at` is set from it. Refused with `no_allowance` when nothing is left; `GET /me/billing` says why beforehand. Response: the detail. Nobody outside the seller's team is emailed: for a private Haggin, the seller sends the link.

### POST /selling/haggins/{id}/close (write, emails the seller's team)

Ends it for good, from `live` or `draft`; the slot is free at once, and a closed draft can't be edited or published later. With `on_expiry: "close"` every open negotiation is closed too (`closed_by: "system"`, `close_reason: "haggin_closed"`, a `negotiation.closed` event each) and each buyer is emailed. Already closed answers `request_rejected`. Response: the detail.

## 5. Negotiations

### GET /selling/negotiations (read, paginated)

The inbox, selling side. Query:

| Param | Values | Notes |
| --- | --- | --- |
| `view` | `attention`, `open`, `waiting`, `accepted`, `closed`, `all` | Default `all`. `attention` is waiting on the seller; `waiting` is waiting on the buyer; `closed` covers rejected, closed and expired. |
| `sort` | `best`, `newest`, `oldest`, `highest`, `ratio`, `licenses`, `potential`, `activity` | Default `activity`. `best` is potential, then ratio, then money normalised to the product's period. |
| `haggin_id` | UUID | One Haggin. |
| `product_id` | UUID | One of that Haggin's products. |
| `min_ratio`, `max_ratio` | number | Offer divided by standard price. `max_ratio=0.3` is below 30%. Offers without a ratio (lifetime) are excluded by either. |
| `min_licenses` | integer | At least this many seats. |
| `item` | `content`, `testimonial`, `ceo_call`, `referral`, `case_study`, `licenses`, `annual_commitment`, `lifetime` | Offers where the buyer gives it. `annual_commitment` also matches yearly offers. |
| `format` | a content format | Only with `item=content`: that format. |
| `q` | text | Buyer name, buyer company, Haggin title or product name contains it. |
| `limit`, `offset` | | |

Filtering and sorting run over the 500 most recently active negotiations that match `view`, `haggin_id` and `product_id`; `pagination.total` never exceeds 500. A filter value the API doesn't know, or a `haggin_id`/`product_id` that isn't a UUID, answers `validation_error`. Response items: negotiation list items (no buyer email).

### GET /selling/negotiations/{id} (read)

Response: negotiation detail, with `offers[]`.

### POST /selling/negotiations/{id}/accept (write, emails the buyer)

Accepts the current offer. Only when `status` is `waiting_for_seller` and the current offer is the buyer's and pending; otherwise `request_rejected`. Creates the deal (`deal` in the response). The buyer is emailed: for a zero amount, straight to the terms (the deal starts at `payment_ready`, marked paid); otherwise told to wait for the payment link. Response: negotiation detail.

### POST /selling/negotiations/{id}/counter (write, emails the buyer)

Only when `waiting_for_seller`. Body, same shape as an offer:

| Field | Notes |
| --- | --- |
| `amount_cents` | Required. Integer, 0 or more. Zero only with items or custom terms. |
| `billing_period` | Required by the schema (`validation_error` without it), then normalised: `is_lifetime` forces `one_time` and clears `annual_commitment`, `annual_commitment` forces `year`, otherwise the product's own period is used. |
| `licenses` | Integer 1 to 100000, or null. |
| `is_lifetime` | Default false. |
| `annual_commitment` | Default false. |
| `price_term_months` | 1 to 120 or null. Dropped on lifetime and one-time. On a yearly price, a multiple of 12 (`request_rejected` otherwise). The seller can set it, change it or drop it. |
| `custom_terms` | Up to 1000. |
| `items[]` | Up to 20. Each `{ party: "buyer" | "seller" (default buyer), type, format (content only), key (custom only), detail (up to 200) }`. Types allowed: `content`, `testimonial`, `ceo_call`, `referral`, `case_study`, `custom`; any other type, a `custom` item with `party: "seller"` or an unknown `key` answers `request_rejected`. Extras asked of the buyer don't have to be enabled on the Haggin. A `custom` item must match one of the Haggin's own extras by `key` and is always `party: "buyer"`; its `label` is filled by the server. `null` in `format`, `key`, `label` or `detail` is read as absent, so an offer's items can be sent back as returned. Duplicates by party and type (and format or key) are collapsed. |

The response is the negotiation detail, now `waiting_for_buyer`, with the new offer last in `offers[]`. Example:

```json
{ "amount_cents": 79000, "billing_period": "month", "licenses": 10, "annual_commitment": true, "price_term_months": 12, "items": [{ "party": "buyer", "type": "testimonial" }] }
```

Read by the server as 790.00 a year for 10 seats, annual commitment, for 12 months, plus a testimonial. Note the period: `annual_commitment` made it `year`.

### POST /selling/negotiations/{id}/reject (write, emails the buyer)

Turns down the current offer and ends the negotiation. Only when `waiting_for_seller`. Body: `{ "reason": "too_low" | "not_our_audience" | "spam" | null }`. The buyer reads the reason. Response: negotiation detail (`rejected`).

### POST /selling/negotiations/{id}/close (write, emails the buyer)

Walks away from an open negotiation, whoever's move it is. Body: `{ "reason": ... | null }`, same values. Already closed answers `request_rejected`. Response: negotiation detail (`closed`).

### POST /selling/negotiations/bulk-reject (write, emails each buyer)

Body: `{ "negotiation_ids": [uuid, ...] }`, 1 to 200. Each one goes through the same rule as reject, with no reason attached; the ones that fail (not yours, not waiting on you, or nonexistent) are skipped, not failed. Duplicate ids are dropped and counted in neither. Response: `{ "rejected": n, "skipped": n }`.

## 6. Deals

### GET /selling/deals (read, paginated)

Newest first by agreement date. Query: `status` (`agreed`, `payment_ready`, `won`), `limit`, `offset`. Response items: deal list items, with `buyer.email` when consented. The money lives under `accepted_offer`.

### GET /selling/deals/{id} (read)

Response: deal detail, with `terms_text`.

### PUT /selling/deals/{id}/payment-link (write, emails the buyer the first time)

Body: `{ "url": "https://..." }`, up to 2048 characters, `https://` only. From `agreed` the deal becomes `payment_ready` and the buyer is emailed. On `payment_ready` the link is replaced silently. Refused on `won` deals and on zero-amount deals (`request_rejected`). Response: deal detail.

### POST /selling/deals/{id}/instructions (write, emails the buyer every time)

Body: `{ "instructions": "..." }`, 1 to 2000 characters. Only on `won` deals ("Wait until they accept the terms" otherwise). Overwrites the previous note; each call sends the email again with replies going to the key's owner. Response: deal detail.

### POST /selling/deals/{id}/paid (write)

Body: `{ "paid": true | false }`. Only on `won` deals with an amount. `true` sets `payment_status: "paid"` and `paid_at`, and emits `deal.paid`; `false` undoes it. Response: deal detail.

## 7. Webhooks

### GET /selling/webhook-endpoint (read)

Response: the endpoint, or `data: null` when none is set. `secret` is `null` for read-only keys.

### PUT /selling/webhook-endpoint (write)

Creates or replaces the one endpoint. Body: `url` (required; `https://`, a public hostname with a dot, no credentials, no IP literal, no private or local address), `enabled` (default true), `events[]` (default `[]`, which means every event; otherwise exactly those). Response: the endpoint with `secret` (`whsec_` plus base64). Rejected URL shapes come back as `validation_error`; a hostname that doesn't resolve, or resolves to a private address, as `request_rejected`. The hostname is resolved again on every delivery and the connection pinned to that address. There is no delete: send `enabled: false`.

### POST /selling/webhook-endpoint/rotate-secret (write)

New secret in the response. The old one stops validating at once. `404` when there is no endpoint.

### POST /selling/webhook-endpoint/test (write)

Queues a `ping` and returns the delivery still `pending` (`attempts: 0`); it is sent right after the response, so check `GET /selling/webhook-deliveries` for the outcome. `404` without an endpoint; `request_rejected` when the endpoint is disabled.

### GET /selling/webhook-deliveries (read, paginated)

The latest 50 deliveries, newest first, with `payload`, `response_status`, `response_body` and `error`. Deliveries older than 30 days are pruned.

### POST /selling/webhook-deliveries/{id}/retry (write)

Tries one delivery again, now, whatever its state. Response: `{ "result": "delivered" | "failed" | "exhausted" | "skipped" }`. `request_rejected` when the endpoint is disabled.

## 8. Webhook payloads

Each delivery is a POST with `Content-Type: application/json`, `User-Agent: HAGGIN-Webhooks/1.0` and the Standard Webhooks headers:

```
webhook-id:         <delivery id, also the envelope id>
webhook-timestamp:  <unix seconds>
webhook-signature:  v1,<base64 HMAC-SHA256 of "{id}.{timestamp}.{raw body}" keyed with the base64-decoded secret after "whsec_">
```

Verify with a five-minute tolerance and a constant-time comparison. Redirects are not followed; answer 2xx from the exact URL configured. Ten seconds to answer.

Envelope: `{ "id", "type", "created_at", "data" }`.

- `ping`: `data: { organization: { id, name } }`.
- `haggin.published`, `haggin.closed`: `data: { haggin: HagginSummary }`.
- `offer.received`, `offer.countered`, `offer.accepted`, `offer.rejected`, `negotiation.closed`, `deal.payment_ready`, `deal.won`, `deal.paid`: `data: { haggin: HagginSummary, negotiation: { id, status, url, closed_by, close_reason, created_at, last_activity_at }, product, buyer (email rule applies), offer (current), offers_count, deal (summary or null) }`.

`offer.received` fires once the buyer's address is confirmed. `offer.countered` fires for either side. `offer.rejected` only for the seller's rejection. `negotiation.closed` covers a walk away by either side, closures by an ending Haggin, and negotiations that expire.

Retries: attempt 1 at once, then after 1, 2, 4, 8, 16, 24 and 24 hours (8 attempts). After 50 consecutive failures the endpoint is disabled with `disabled_reason: "too_many_failures"` and the team is emailed; re-enable it with `PUT`.

## 9. Errors

`{ "error": { "code", "message", "fields"?, "request_id" } }`.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_json` | The body is not JSON. |
| 400 | `invalid_idempotency_key` | The header is longer than 255 characters. |
| 401 | `invalid_api_key` | No key, a malformed one, or a revoked one. Same answer for all three. |
| 403 | `insufficient_access` | A read-only key tried to write. |
| 403 | `plan_required` | Selling through the API is part of Pro and Business. |
| 403 | `no_organization` | The account has no company yet. |
| 403 | `mode_not_allowed` | The key was not created for selling. |
| 403 | `no_allowance` | No slot or allowance left to publish with. |
| 404 | `not_found` | Doesn't exist, isn't yours, or the id is not a UUID. |
| 409 | `idempotency_in_progress` | The first request with this key is still running. |
| 413 | `body_too_large` | Over 100 KB. |
| 422 | `validation_error` | `fields` maps each bad field (snake_case path) to its messages. |
| 422 | `request_rejected` | Valid, but not allowed in the current state, or refused by the Haggin's rules (term not in whole years, unknown extra). The message says why. |
| 422 | `idempotency_key_reused` | Same key, different method, path or body. |
| 429 | `rate_limited` | `Retry-After` says when. Also for 20 failed authentications from one address in five minutes. |
| 500 | `internal_error` | Safe to retry. Send HAGGIN the `request_id`. |
