Three ways to create a negotiation programmatically, plus the one route that writes a sealed value after the fact.
| Route | Auth | Rate limit | Sealed fields |
|---|---|---|---|
POST /api/v1/negotiations | Per-key bearer token (ds_...) | 50/hour per key | Allowed (JSON body) |
POST /api/webhook/negotiations | Shared webhook key | 100/hour, all callers | Allowed (JSON body) |
GET /api/webhook/negotiations | Shared webhook key | 100/hour, all callers | Rejected with 400 |
POST /api/negotiations/bottom-line | Host access token | Global 60/min only | Sets one, write-only |
"Sealed fields" means bottomLine, batna, roiThoughts, and pitch.
- Open
/adminand sign in with the admin password. - Go to the API Keys tab.
- Enter a name that says who is holding it, and click generate.
- Copy the key.
The key authenticates /api/v1/* as Authorization: Bearer ds_.... Revoke from the same list; revocation takes effect immediately.
Two things to know before you hand one to somebody outside ARM:
- A key is not scoped to its own deals.
GET /api/v1/negotiationsreturns every negotiation in the system - titles, status, both parties' names and emails, asking prices. Sealed values are excluded, but the deal list is not. A second key holder can read your book. - Keys are stored in plain text and the admin list returns them in full, so "shown only once" is not literally true today.
Both are known and tracked separately from this document. Until they are fixed, treat API keys as ARM-internal.
Separate from the ds_... keys, and used by both /api/webhook/negotiations handlers. Set or rotate it at /admin -> Settings -> Webhook API Key. There is exactly one, so rotating it revokes every caller and every previously issued URL at the same time.
Accepted three ways, in this order:
X-Webhook-Key: <key>Authorization: Bearer <key>?key=<key>in the query string
POST /api/v1/negotiationsCreate a negotiation with a per-key bearer token.
Required: title, description, hostName, hostEmail, guestEmail, askingPrice.
Required-adjacent: hostSide - SELLER (default) or BUYER. When BUYER, send initialOffer instead of askingPrice. This is easy to miss and changes which opening number is required.
Optional: bottomLine, additionalTerms, batna, roiThoughts, productUrl, tier (FREE|STANDARD|PREMIUM), maxRounds, sendInvite.
curl -X POST https://dealseal.io/api/v1/negotiations \
-H "Authorization: Bearer ds_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"title": "Used Excavator",
"description": "2018 Cat 320, 6400 hours, good condition.",
"hostName": "Alan Ross Machinery",
"hostEmail": "rr@alanross.biz",
"guestEmail": "buyer@example.com",
"hostSide": "SELLER",
"askingPrice": 85000,
"bottomLine": 72000
}'Rate limit: 50/hour per key.
POST /api/webhook/negotiationsCreate a negotiation with the shared key and a JSON body. Party 1 defaults to rr@alanross.biz / Alan Ross Machinery; Party 2's email is required.
Every deal created here is FREE tier with paymentStatus: NONE - unlike the v1 route, tier is not caller-controlled.
curl -X POST https://dealseal.io/api/webhook/negotiations \
-H "X-Webhook-Key: your_webhook_key" \
-H "Content-Type: application/json" \
-d '{
"party2Email": "buyer@example.com",
"title": "Used Excavator",
"description": "2018 Cat 320, 6400 hours, good condition.",
"hostSide": "SELLER",
"askingPrice": 85000,
"bottomLine": 72000
}'Returns 201:
{
"id": "9f3c...",
"status": "PENDING_GUEST",
"hostSide": "SELLER",
"party1Email": "rr@alanross.biz",
"party2Email": "buyer@example.com",
"inviteUrl": "https://dealseal.io/invite/...",
"dashboardUrl": "https://dealseal.io/negotiate/9f3c.../host?token=...",
"createdAt": "2026-08-08T15:00:00.000Z"
}dashboardUrl carries the host access token. Do not log it, and do not put it anywhere the counterparty can reach.
Rate limit: 100/hour, shared with the GET handler.
GET /api/webhook/negotiationsThe same creation path, driven entirely by a URL, for systems that can only issue a GET. Build one at /admin -> Settings -> Webhook URL Builder.
https://dealseal.io/api/webhook/negotiations?key=YOUR_KEY&party2Email=buyer%40example.com&title=Used%20Excavator&description=2018%20Cat%20320%2C%206400%20hours%2C%20good%20condition.&hostSide=SELLER&askingPrice=85000curl -sS -G https://dealseal.io/api/webhook/negotiations \
--data-urlencode "key=your_webhook_key" \
--data-urlencode "party2Email=buyer@example.com" \
--data-urlencode "title=Used Excavator" \
--data-urlencode "description=2018 Cat 320, 6400 hours, good condition." \
--data-urlencode "hostSide=SELLER" \
--data-urlencode "askingPrice=85000"Returns 201:
{
"id": "9f3c...",
"status": "PENDING_GUEST",
"hostSide": "SELLER",
"party2Email": "buyer@example.com",
"createdAt": "2026-08-08T15:00:00.000Z"
}Full parameter table: `docs/OPERATIONS.md`.
Sealed fields return 400 and write nothing.
{
"error": "Sealed fields cannot be supplied in a URL",
"fields": ["bottomLine"],
"sealedFields": ["bottomLine", "batna", "roiThoughts", "pitch"]
}A query string is recorded in reverse-proxy logs, browser history, and Referer headers. Set the bottom line afterwards instead - see below.
No `dashboardUrl` and no `inviteUrl` in the response, and Cache-Control: no-store. Anything that fetches a URL speculatively receives the response body, and that body must carry no token at all: dashboardUrl holds the host access token, inviteUrl holds the invite token that lets its bearer submit the guest side. Both are delivered by email, to the people they belong to. POST returns both, because a JSON request body is not cached, logged, or prefetched.
Idempotent for 10 minutes. The webhook key plus the parameters, sorted into a canonical order, are hashed into a fingerprint; a repeat inside the window returns the original deal instead of creating a second one, and sends no second invite email.
{ "id": "9f3c...", "duplicate": true }Two identical requests arriving together: one creates, the other gets 409 with Retry-After: 5.
| Code | Meaning |
|---|---|
201 | Created. |
200 | Duplicate inside the window; body carries the existing id. |
400 | Validation failure, or a sealed field in the query string. |
401 | Missing or wrong webhook key. |
409 | An identical request is mid-flight. Retry. |
429 | 100/hour exceeded. |
503 | Content moderation unavailable. Retryable; nothing was created. |
POST /api/negotiations/bottom-lineSets Party 1's sealed number after the deal exists. This is what makes a URL-created deal enforceable, since the URL cannot carry the number.
curl -X POST https://dealseal.io/api/negotiations/bottom-line \
-H "Content-Type: application/json" \
-d '{"token": "<host access token>", "bottomLine": 72000}'- `204` on success, with no body. The value is never echoed back.
- `409` on any refusal, with one fixed message. Deal already under way, bad value, wrong state - the caller cannot tell which, by design.
- `404` if the token does not belong to a host.
The negotiation is resolved from the access token, via that token's own host relation. No negotiation id is accepted from the caller, so there is no ownership check that can be reordered into uselessness. A guest token has no host relation and gets the 404.
The write only lands while the deal is PENDING_GUEST. The status guard and the write share one transaction and the guard is a conditional update, so a guest submitting at the same moment either commits first (the write is refused) or waits (the floor lands before the run starts). It cannot interleave into a run that already read null.
Known window: if the guest submits before the host has set a floor, the negotiation starts without one and the engine has nothing to enforce. The runner does not currently hold for this.
Party.accessToken is a Prisma @default(uuid()) - a v4 UUID, 36 characters, 122 bits of randomness. It is not the 256-bit value from generateToken() in src/lib/tokens.ts, which this field does not use. It is a bearer credential with no expiry: whoever holds it can read the deal and set the bottom line while the deal is pending.