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.59000withlicenses: 10andbilling_period: "month"is 590 a month for ten seats, not per seat.billing_period:month,yearorone_time.annual_commitment: truemeans the buyer commits to a year.is_lifetime: truemeans one payment, forever.licenses: seats, when the product isper_seator the seller opened that lever.price_term_months: how long the agreed price holds.nullmeans 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 (acontentitem carries aformat,customones carry the seller's ownlabel).party: "seller"is what the seller adds (an onboarding call, extra seats).detailis 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.
GET /me:selling.enabledmust be true. ThenGET /me/billing. Ifpublish.allowedis false, orpublish.sourceis not"plan", say so and stop: the reason andupgrade_hintare in the answer.- 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.
POST /selling/hagginswithkind: "private",invited_name,invited_email, one product, the options,price_term_monthsif wanted,duration_daysandon_expiry. It comes back as adraft.- Show the draft: title, product and price, what can be negotiated, time limit, duration. Ask for the yes to publish.
POST /selling/haggins/{id}/publish. Then give the sellerpublic_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
GET /selling/negotiations?view=attention&sort=best.attentionis what waits on the seller.bestputs potential first, then ratio, then money.- For each Haggin involved,
GET /selling/haggins/{id}once, for the standard prices, the levers and the internal notes. - 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. - Present all of them in one message, best first, each in the block above. Then wait.
- For each yes, send one write with a fresh
Idempotency-Key, then report what came back (status, anddeal.idafter 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 atpayment_ready, marked paid, and never emitsdeal.payment_ready. - Counter:
POST /selling/negotiations/{id}/counterwith the same shape as an offer.amount_centsandbilling_periodare required; withoutbilling_periodthe request is avalidation_error. Rules the server applies:annual_commitment: truemakes the periodyear;is_lifetime: truemakes itone_time; otherwise the period is the product's, whatever you send.is_lifetime: truealso clearsannual_commitment.price_term_monthson 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 withparty: "seller". Extras you ask of the buyer don't have to be switched on for the Haggin. Acustomitem must be one of the Haggin's own extras, bykey, and is alwaysparty: "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 customkeyor an item of typelicenses,annual_commitmentorlifetimecome back asrequest_rejected, notvalidation_error. Sending an offer'sitems[]back as the API returned them works:nullinformat,key,labelanddetailis read as absent. - Pass:
POST /selling/negotiations/{id}/reject, optionalreason(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
GET /selling/deals?status=agreed: deals waiting for a payment link.buyer.emailis here once the buyer consented to share it. The money is underaccepted_offer(amount_cents,currency,billing_period), not at the top level.- The seller gives you the link from their own checkout (Stripe, Paddle, anything),
https://only.PUT /selling/deals/{id}/payment-linkwith{"url": "https://..."}. The first time, the deal becomespayment_readyand the buyer gets an email. A deal with a zero amount has no link: the buyer goes straight to the terms. - The deal becomes
wonwhen the buyer accepts the frozen terms in the app. Watch for it with thedeal.wonwebhook, or pollGET /selling/deals?status=won. - Once won,
POST /selling/deals/{id}/instructionstells 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. POST /selling/deals/{id}/paidwith{"paid": true}when the seller says the money landed.falseundoes it. Won deals with an amount only; a zero-amount deal is already marked paid.
Clean the noise
- Find it:
GET /selling/negotiations?view=attention&max_ratio=0.3, orq=for a name or company, oritem=for offers where the buyer gives a given extra.min_licenses,min_ratioandhaggin_idnarrow further. A filter value the API doesn't know answersvalidation_error. - 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.
- On the seller's yes,
POST /selling/negotiations/bulk-rejectwithnegotiation_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.
PUT /selling/webhook-endpointwithurl(https, a public hostname, never an IP; one that doesn't resolve or resolves to a private address is refused withrequest_rejected),enabledandevents. An emptyeventslist means every event, including ones added later. The answer carriessecret(whsec_...) for full-access keys. Put it in the seller's system; don't paste it around.POST /selling/webhook-endpoint/rotate-secretwhen it leaks: the old one dies at once. There is no delete;enabled: falseswitches it off.POST /selling/webhook-endpoint/testqueues apingand returns the delivery stillpending; it is sent right after.GET /selling/webhook-deliveriesshows what the endpoint answered.POST /selling/webhook-deliveries/{id}/retrytries one again now.- 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 afterwhsec_; reject timestamps more than five minutes away. - Retries: eight attempts over about three days. After 50 consecutive failures the endpoint is paused and the seller gets an email; set it
enabled: trueagain 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-Keyheader (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, markedIdempotent-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_progressmeans the first attempt is still running: wait a moment, retry with the same key; after about two minutes the key is released. After a500, 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-RemainingandX-RateLimit-Reset; on writes they may show the write ceiling of 30. Refused writes count too. A429hasRetry-Afterin seconds. Wrong keys are limited per address too: 20 failures in five minutes. - Pagination.
limit(1 to 100, default 50) andoffset. Lists answerpagination.totalandhas_more. The inbox filters and sorts over the 500 most recently active negotiations that matchview,haggin_idandproduct_id: narrow with those first. There,totalnever exceeds 500. - Bodies up to 100 KB. Where a text is empty you get
null, and you can sendnullback. Everything else is inreferences/api.md: the error table below skips three codes you only get with a broken request (invalid_json400,invalid_idempotency_key400,body_too_large413). - Errors always look the same:
error.code(stable, branch on it),error.message(for people),error.fieldson 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.
